Letter Swap <mv-letter-swap>

Per-letter roll for nav links and buttons: on hover or keyboard focus each letter rolls away to reveal its twin, in a cascade, or turns as a small 3D cube. The real letters stay in the DOM and in their own fixed boxes, so the label never reflows, wraps between words, stays selectable and can change at runtime.

CategoryAnimated text
TypeWeb Component (<mv-letter-swap>)
Statusstable
Keywordsletter, swap, roll, flip, 3d, hover, nav, button, stagger, accessible

When to use

  • Nav links or button labels should roll their letters on hover and keyboard focus
  • A 3D cube flip of each letter suits a playful or technical brand
  • A short label should roll once when it enters the viewport, on page load or on demand
  • The revealed letters should take an accent color while a control is hovered

Avoid when

  • A different label must roll in on hover, or a highlighter should glide between phrases → use Rolling Text instead
  • Long sentences or paragraphs; the per-letter roll is meant for short labels
  • The label is underlined; per-letter boxes do not carry the parent text decoration

Install

node scripts/add.mjs letter-swap --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["letter-swap"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/letter-swap/letter-swap.js, components/letter-swap/letter-swap.css.

Usage

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

<div style="display:grid;gap:2.25rem;justify-items:center;text-align:center">
  <nav aria-label="Demo" style="display:flex;gap:.25rem;padding:.35rem;border:1px solid var(--mv-border);border-radius:999px;background:var(--mv-surface);box-shadow:var(--mv-shadow-sm)">
    <a href="#" style="padding:.45rem .9rem;border-radius:999px;color:var(--mv-fg);text-decoration:none;font:500 .9rem var(--mv-font-sans)"><mv-letter-swap>Work</mv-letter-swap></a>
    <a href="#" style="padding:.45rem .9rem;border-radius:999px;color:var(--mv-fg);text-decoration:none;font:500 .9rem var(--mv-font-sans)"><mv-letter-swap stagger-from="center">Studio</mv-letter-swap></a>
    <a href="#" style="padding:.45rem .9rem;border-radius:999px;color:var(--mv-fg);text-decoration:none;font:500 .9rem var(--mv-font-sans)"><mv-letter-swap stagger-from="random">Journal</mv-letter-swap></a>
    <a href="#" style="padding:.45rem .9rem;border-radius:999px;background:var(--mv-fg);color:var(--mv-bg);text-decoration:none;font:500 .9rem var(--mv-font-sans)"><mv-letter-swap direction="down">Contact</mv-letter-swap></a>
  </nav>

  <p style="margin:0;font:800 clamp(2.2rem,6vw,3.8rem)/1 var(--mv-font-sans);letter-spacing:-.045em;text-transform:uppercase">
    <mv-letter-swap variant="flip" trigger="view" loop stagger="45" duration="700">Creative</mv-letter-swap>
    <span style="color:var(--mv-fg-muted)">by nature</span>
  </p>

  <div style="display:flex;gap:.75rem;flex-wrap:wrap;justify-content:center">
    <button class="mv-button" data-size="lg"><mv-letter-swap variant="flip" stagger="30">Start for free</mv-letter-swap></button>
    <button class="mv-button" data-size="lg" data-variant="outline"><mv-letter-swap stagger="15" duration="420" easing="out" style="--mv-letter-swap-twin:var(--mv-accent)">Watch the demo</mv-letter-swap></button>
  </div>
</div>

API

Attributes

NameTypeDefaultDescription
textstringLabel to roll. Defaults to the element text; changing it rebuilds the letters.
variantslide | flipslideslide: the letter slides; flip: a rotating 3D cube.
triggerhover | view | load | manualhoverhover follows the parent link or button (mouse or pen hover, keyboard focus); view and load roll once; manual waits for replay().
directionup | downupRoll direction: up (the twin rises from below) or down.
durationnumber500Duration per letter (ms).
staggernumber22Delay between letters (ms).
stagger-fromstart | center | end | randomstartWhere the cascade starts.
delaynumber0Initial delay (ms).
easingCSS easingvar(--mv-ease-in-out)Easing curve: any CSS easing, or a token name (out, in, in-out, emphasized, spring, bounce, snappy).
loopbooleanWith view, load or manual: keeps rolling while visible, pausing 1.6 s between rolls.

Properties

NameTypeDescription
labelstringRead only. The label as announced to screen readers.

Methods

NameDescription
replay()Rolls every letter once and settles back invisibly. Returns a Promise resolved when done (at once under reduced motion).

CSS variables

NameDefaultDescription
--mv-letter-swap-leading1.2Unitless line height of each letter box (the clipping window). The extra leading is cancelled, so the line keeps its height.
--mv-letter-swap-easingvar(--mv-ease-in-out)Default easing curve.
--mv-letter-swap-twincurrentColorColor of the revealed twin while hovered or focused (a replay always lands on identical letters).
--mv-letter-swap-perspective20emPerspective depth (flip variant).

Accessibility

Screen readers get the label once, as plain text; the rolling letters are aria-hidden, so there is no per-letter reading. The real letters stay in the DOM (selectable, copied once) and the twin is drawn by CSS. Hover follows the parent control and its keyboard :focus-visible; touch does not trigger it. Each letter keeps a fixed box, so nothing reflows. Reduced motion, print and forced colors show static text.