웹 디자인 이펙트

Staggered character reveal

Every glyph rises out of its own mask, 26ms apart. The interesting decisions are all in the splitting: per word so the text still wraps, and one aria-label so a screen reader does not read it letter by letter.

How it works

Numbers

Stagger
26ms per character
Duration
620ms
Easing
cubic-bezier(0.16, 1, 0.3, 1)
Travel
translateY(110%) → 0
Opacity
300ms linear (shorter on purpose)
Descender pad
0.16em

Prompt

Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.

Build a staggered character reveal in vanilla HTML/CSS/JS. Split the text into WORDS first and characters inside each word — never split the whole string into characters, or a line break can land mid-word. Wrap each word in a span with overflow: hidden, display: inline-block, vertical-align: top, plus padding-bottom: 0.16em and margin-bottom: -0.16em so descenders are not clipped by the mask. Each character span starts at translateY(110%) and opacity 0; when the parent gets a .go class they animate to none/1 with transition: transform 620ms cubic-bezier(0.16, 1, 0.3, 1), opacity 300ms linear and transition-delay: calc(var(--i) * 26ms), where --i is the running character index. Keep the opacity ramp deliberately shorter than the movement. Set aria-label on the parent to the original sentence and aria-hidden on every generated span, so screen readers read one string rather than individual letters. Trigger on IntersectionObserver at threshold 0.4. For replay, remove the class, force a reflow by reading document.body.offsetWidth, then re-add it. Under prefers-reduced-motion: reduce, render the text in its final state with no transition.

Source

The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.

staggered-character-reveal.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staggered character reveal</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    min-height: 100vh; display: grid; place-items: center;
    padding: 0 9%;
    background: radial-gradient(120% 100% at 20% 0%, #f7f8fa, #e9ecf1 70%);
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
    color: #12141a;
    overflow: hidden;
  }
  .stack { max-width: 34ch; }

  .eyebrow {
    display: block; margin-bottom: 14px;
    font-size: 10.5px; font-weight: 600;
    letter-spacing: 0.22em; text-transform: uppercase;
    color: #12141a5c;
  }
  h1 {
    font-size: clamp(23px, 5.4vw, 42px);
    font-weight: 700; letter-spacing: -0.032em; line-height: 1.16;
  }
  .sub {
    margin-top: 14px;
    font-size: clamp(12px, 1.7vw, 14.5px); line-height: 1.6; color: #4d545f;
  }

  /* Each word is its own mask. Masking per word rather than per line lets the
     text wrap normally — a single line-level mask breaks the moment the copy
     reflows at a narrower width, which is where this effect usually dies. */
  .w {
    display: inline-block; overflow: hidden;
    vertical-align: top;
    /* Descenders (g, y, p) get clipped by a mask sized to the line box, so the
       mask is padded and pulled back by the same amount. */
    padding-bottom: 0.16em; margin-bottom: -0.16em;
  }
  .c {
    display: inline-block;
    transform: translateY(110%);
    opacity: 0;
  }
  .go .c {
    transform: none; opacity: 1;
    transition:
      transform 620ms cubic-bezier(0.16, 1, 0.3, 1),
      opacity 300ms linear;
    transition-delay: calc(var(--i) * 26ms);
  }

  .rerun {
    position: fixed; right: 14px; bottom: 14px;
    display: inline-flex; align-items: center; gap: 6px;
    font: 500 11.5px/1 inherit; color: #4d545f;
    background: #ffffffd6;
    -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
    border: 1px solid #12141a1f; border-radius: 99px;
    padding: 7px 13px; cursor: pointer;
    transition: color 180ms ease, border-color 180ms ease;
  }
  .rerun::before { content: "↻"; font-size: 13px; line-height: 0.8; }
  .rerun:hover { color: #12141a; border-color: #12141a3d; }

  @media (prefers-reduced-motion: reduce) {
    .c { transform: none; opacity: 1; }
    .go .c { transition: none; }
    .rerun { transition: none; }
  }
</style>

<div class="stack">
  <span class="eyebrow">Staggered reveal</span>
  <h1 data-split>Immersive entertainment that did not exist before</h1>
  <p class="sub" data-split>Each glyph rises out of its own mask, 26ms apart.</p>
</div>
<button class="rerun" id="rerun">Replay</button>

<script>
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

  function split(el) {
    // Words first, characters inside them. Splitting the whole string into
    // characters and letting them wrap would let a line break land mid-word.
    const words = el.textContent.split(/(\s+)/);
    el.textContent = "";
    let n = 0;
    for (const word of words) {
      if (/^\s+$/.test(word)) { el.appendChild(document.createTextNode(word)); continue; }
      const w = document.createElement("span");
      w.className = "w";
      for (const ch of word) {
        const c = document.createElement("span");
        c.className = "c";
        c.style.setProperty("--i", n++);
        c.textContent = ch;
        w.appendChild(c);
      }
      el.appendChild(w);
    }
    // The visible text is now split across dozens of spans, which a screen
    // reader would announce one letter at a time. One aria-label on the parent
    // and the pieces hidden puts it back to a single sentence.
    el.setAttribute("aria-label", el.textContent);
    [...el.querySelectorAll(".w")].forEach((w) => w.setAttribute("aria-hidden", "true"));
    return el;
  }

  const targets = [...document.querySelectorAll("[data-split]")].map(split);

  const play = () => {
    targets.forEach((t) => t.classList.remove("go"));
    // Force a reflow so removing and re-adding the class in the same frame
    // actually restarts the transition instead of being coalesced away.
    void document.body.offsetWidth;
    targets.forEach((t) => t.classList.add("go"));
  };

  if (reduce) targets.forEach((t) => t.classList.add("go"));
  else {
    const io = new IntersectionObserver((es) => {
      if (es.some((e) => e.isIntersecting)) { play(); io.disconnect(); }
    }, { threshold: 0.4 });
    io.observe(targets[0]);
  }
  document.getElementById("rerun").addEventListener("click", play);
</script>

Where it breaks

Seen in

@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:47