Skip to content
On this page Reveal

Motion

Text motion

Named text effects with exact timing: split reveal, scramble, odometer, count up, marquee, typewriter, shine sweep and blur in.

Text is the cheapest place to add motion and the easiest to break: split words that a screen reader spells out, counters that announce every frame, tickers nobody can stop. Every demo here keeps the real sentence or number as the accessible name, hides the animated copy with aria-hidden, shows the final text under prefers-reduced-motion, and can be taken over by touching it. Ask an AI for one by name or by number: Use #300 or Use a split text reveal on the hero headline.

Reveal

#300

Split text reveal

Called a split text reveal (also word-by-word reveal, texto dividido por palavra). Use it for a hero or section headline that should arrive with intent: each word rises out of a mask, 640ms with a 70ms stagger. The unsplit sentence stays the heading's accessible name. Ask: "Use #300" or "a split text reveal".

Reference #300 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Button, MotionStage } from '@codeatlasdev/ui';

  const headline = 'Close the books in one afternoon';
  const words = headline.split(' ');

  // Takeover: each click of the button (or Enter on it) re-runs the reveal on demand.
  let manual = $state(0);
</script>

<MotionStage label="Split text reveal: a headline rises in word by word" loop={3600}>
  {#snippet children(m)}
    <div class="split">
      <!-- The unsplit sentence is the accessible name; the animated words are hidden from AT. -->
      {#key `${m.cycle}-${manual}`}
        <h3 class="split__title" aria-label={headline}>
          {#each words as word, i (i)}
            <span class="split__mask" aria-hidden="true"
              ><span class="split__word" class:split__word--run={!m.reduced} style="--i:{i}">{word}</span
              ></span
            >{' '}
          {/each}
        </h3>
      {/key}
      <p class="split__sub">Reconciliation, approvals and the audit trail, on one screen.</p>
      <Button size="sm" variant="outlined" onclick={() => manual++}>Reveal again</Button>
    </div>
  {/snippet}
</MotionStage>

<style>
  .split { display: grid; gap: var(--ca-space-3); justify-items: start; max-width: 28rem; }
  .split__title {
    margin: 0;
    font-family: var(--ca-font-display);
    font-size: var(--ca-font-size-h3);
    font-weight: var(--ca-font-weight-heading);
    line-height: 1.15;
    color: var(--ca-text-primary);
  }
  /* The mask clips the word while it rises from below its own line box. */
  .split__mask { display: inline-block; overflow: hidden; vertical-align: top; padding-block-end: 0.12em; }
  .split__word { display: inline-block; }
  .split__word--run {
    animation: split-rise 640ms cubic-bezier(0.2, 0, 0, 1) calc(var(--i) * 70ms) both;
  }
  .split__sub { margin: 0; color: var(--ca-text-secondary); }
  @keyframes split-rise {
    from { opacity: 0; transform: translateY(110%); }
    to { opacity: 1; transform: translateY(0); }
  }
</style>
#307

Blur in

Called a blur in (also blur reveal, texto com desfoque). Use it for a softer, cinematic title: each line sharpens from a 12px blur in 900ms, 160ms apart. Ask: "Use #307" or "a blur in".

Reference #307 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Button, MotionStage } from '@codeatlasdev/ui';

  const lines = ['Meet your new', 'finance co-pilot'];
  let manual = $state(0);
</script>

<MotionStage label="Blur in: a headline sharpens from a blur, line by line" loop={3400}>
  {#snippet children(m)}
    <div class="bi">
      {#key `${m.cycle}:${manual}`}
        <h3 class="bi__title" aria-label={lines.join(' ')}>
          {#each lines as line, i (i)}
            <span class="bi__line" class:bi__line--run={!m.reduced} style="--i:{i}" aria-hidden="true">{line}</span>
          {/each}
        </h3>
      {/key}
      <Button size="sm" variant="outlined" onclick={() => manual++}>Sharpen again</Button>
    </div>
  {/snippet}
</MotionStage>

<style>
  .bi { display: grid; gap: var(--ca-space-3); justify-items: start; }
  .bi__title {
    display: grid;
    margin: 0;
    font-family: var(--ca-font-display);
    font-size: var(--ca-font-size-h2);
    font-weight: var(--ca-font-weight-heading);
    line-height: 1.1;
    color: var(--ca-text-primary);
  }
  .bi__line { display: block; }
  .bi__line--run { animation: blur-in 900ms cubic-bezier(0.2, 0, 0, 1) calc(var(--i) * 160ms) both; }
  @keyframes blur-in {
    from { opacity: 0; filter: blur(12px); transform: translateY(8px); }
    to { opacity: 1; filter: blur(0); transform: translateY(0); }
  }
</style>

Transform and decode

#301

Text scramble

Called a text scramble (also decode effect, character scramble, embaralhar texto). Use it for a status label that just changed or a security screen: random glyphs resolve to the real letters, one every 55ms. Hover or focus re-runs it. Ask: "Use #301" or "a text scramble on hover".

Reference #301 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';

  const target = 'ENCRYPTED  →  DECRYPTED';
  const glyphs = '!<>-_\\/[]{}=+*^?#0123456789';
  const STEP = 40; // 40ms: how often the random glyphs are redrawn
  const SETTLE = 55; // 55ms per letter, linear: a constant pace with no easing

  let text = $state(target);
  let raf = 0;

  /** Decode left to right: letters before the cursor are final, the rest are random glyphs. */
  function decode() {
    cancelAnimationFrame(raf);
    const start = performance.now();
    let last = -STEP;
    const frame = (now: number) => {
      const t = now - start;
      if (now - last >= STEP) {
        last = now;
        const locked = Math.floor(t / SETTLE);
        text = [...target]
          .map((c, i) => (c === ' ' || i < locked ? c : glyphs[Math.floor(Math.random() * glyphs.length)]))
          .join('');
      }
      if (t / SETTLE < target.length) raf = requestAnimationFrame(frame);
      else text = target;
    };
    raf = requestAnimationFrame(frame);
  }

  /** Autoplay: every stage cycle decodes again; reduced motion shows the final text. */
  const autoplay = (m: MotionCtx) => () => {
    const { cycle, reduced } = m;
    void cycle;
    if (reduced) {
      cancelAnimationFrame(raf);
      text = target;
      return;
    }
    decode();
    return () => cancelAnimationFrame(raf);
  };
</script>

<MotionStage label="Scramble: a line of text decodes from random glyphs" loop={2800}>
  {#snippet children(m)}
    <div class="scramble" {@attach autoplay(m)}>
      <!-- The stable sentence is the accessible name; the flickering glyphs are decoration. -->
      <button
        type="button"
        class="scramble__btn"
        aria-label={target}
        onpointerenter={() => !m.reduced && decode()}
        onfocus={() => !m.reduced && decode()}
      >
        <span aria-hidden="true" class="scramble__text">{text}</span>
      </button>
      <p class="scramble__hint">Hover or focus the line to decode it again.</p>
    </div>
  {/snippet}
</MotionStage>

<style>
  .scramble { display: grid; gap: var(--ca-space-3); justify-items: start; }
  .scramble__btn {
    all: unset;
    cursor: pointer;
    padding: var(--ca-space-2) var(--ca-space-3);
    border-radius: var(--ca-radius-md);
    background: var(--ca-surface-sunken);
    color: var(--ca-brand-text);
    font-family: var(--ca-font-mono);
    font-size: var(--ca-font-size-lg);
    font-weight: var(--ca-font-weight-semibold);
    letter-spacing: 0.04em;
    white-space: pre;
  }
  .scramble__btn:focus-visible { outline: 2px solid var(--ca-focus-ring); outline-offset: 2px; }
  .scramble__text { display: inline-block; min-inline-size: 23ch; }
  .scramble__hint { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
</style>
#304

Typewriter

Called a typewriter (also typing effect, máquina de escrever). Use it for AI prompt inputs and cycling taglines: 55ms per character, a 1100ms hold, 28ms per deleted character and a caret blinking at 1060ms. Type in the field to take over. Ask: "Use #304" or "a typewriter with deletion".

Reference #304 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';

  const phrases = ['Invoice #4821 is paid.', 'Reconcile 312 transactions.', 'Ask anything about Q3.'];
  const TYPE = 55; // 55ms per character, linear (a constant pace)
  const ERASE = 28; // 28ms per deleted character
  const HOLD = 1100; // 1100ms pause on a finished phrase

  let shown = $state(phrases[0]);
  let draft = $state('');

  /** One autoplay cycle types a phrase, holds it, then deletes it; the next cycle picks the next phrase. */
  const autoplay = (m: MotionCtx) => () => {
    if (m.reduced) {
      shown = phrases[0];
      return;
    }
    const phrase = phrases[m.cycle % phrases.length];
    let i = 0;
    let dir = 1;
    let timer: ReturnType<typeof setTimeout>;
    const step = () => {
      i += dir;
      shown = phrase.slice(0, i);
      if (dir === 1 && i === phrase.length) {
        dir = -1;
        timer = setTimeout(step, HOLD);
      } else if (dir === -1 && i === 0) {
        return;
      } else timer = setTimeout(step, dir === 1 ? TYPE : ERASE);
    };
    shown = '';
    timer = setTimeout(step, TYPE);
    return () => clearTimeout(timer);
  };
</script>

<MotionStage label="Typewriter: a prompt types itself, holds, then deletes" loop={4800}>
  {#snippet children(m)}
    <div class="tw" {@attach autoplay(m)}>
      <label class="tw__label" for="tw-input">Assistant prompt</label>
      <div class="tw__box">
        <!-- The finished phrase is announced once by the input below; the typing is decoration. -->
        <span class="tw__text" aria-hidden="true">{m.playing || m.reduced ? shown : draft || shown}</span><span
          class="tw__caret"
          class:tw__caret--blink={!m.reduced}
          aria-hidden="true"
        ></span>
      </div>
      <input
        id="tw-input"
        class="tw__input"
        placeholder="Type to take over"
        autocomplete="off"
        bind:value={draft}
      />
    </div>
  {/snippet}
</MotionStage>

<style>
  .tw { display: grid; gap: var(--ca-space-2); inline-size: 100%; max-width: 26rem; }
  .tw__label { font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); }
  .tw__box {
    display: flex;
    align-items: center;
    min-block-size: 3.25rem;
    padding: var(--ca-space-3) var(--ca-space-4);
    border: 1px solid var(--ca-outline-control);
    border-radius: var(--ca-radius-lg);
    background: var(--ca-surface-raised);
    font-family: var(--ca-font-mono);
    font-size: var(--ca-font-size-body1);
    color: var(--ca-text-primary);
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }
  .tw__caret {
    display: inline-block;
    inline-size: 2px;
    block-size: 1.2em;
    margin-inline-start: 2px;
    background: var(--ca-brand-main);
  }
  .tw__caret--blink { animation: tw-blink 1060ms steps(1, end) infinite; }
  .tw__input {
    min-block-size: var(--ca-touch-target);
    padding: var(--ca-space-2) var(--ca-space-3);
    border: 1px solid var(--ca-outline-control);
    border-radius: var(--ca-radius-md);
    background: var(--ca-surface-base);
    color: var(--ca-text-primary);
    font: inherit;
  }
  .tw__input:focus-visible { outline: 2px solid var(--ca-focus-ring); outline-offset: 2px; }
  @keyframes tw-blink {
    0%, 49% { opacity: 1; }
    50%, 100% { opacity: 0; }
  }
</style>

Numbers

#302

Odometer roll

Called an odometer roll (also rolling digits, slot counter, contador mecânico). Use it for a KPI that changes in place: each digit slides to its new value in 900ms, the ones place first, 60ms apart. Type a number to roll it. Ask: "Use #302" or "an odometer for the revenue KPI".

Reference #302 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';

  const values = [48210, 51904, 49377, 56012];
  const digits = '0123456789'.split('');

  let value = $state(values[0]);
  let typed = $state('48210');

  /** Autoplay walks through the KPI values only while playing; reduced motion pins the last one. */
  const autoplay = (m: MotionCtx) => () => {
    const { cycle, reduced, playing } = m;
    if (reduced) value = values[values.length - 1];
    else if (playing) value = values[cycle % values.length];
    else return;
    typed = String(value);
  };

  function onType(e: Event) {
    typed = (e.currentTarget as HTMLInputElement).value.replace(/\D/g, '').slice(0, 7);
    value = Number(typed || 0);
  }

  // Each digit is a column of 0-9; the column slides to the digit with translateY.
  const cols = $derived(String(value).split('').map(Number));
</script>

<MotionStage label="Odometer: a KPI value rolls digit by digit" loop={2600}>
  {#snippet children(m)}
    <div class="odo" {@attach autoplay(m)}>
      <p class="odo__label">Monthly revenue (USD)</p>
      <!-- One stable value for assistive tech; the rolling columns are decoration. -->
      <p class="odo__value" role="img" aria-label={`${value.toLocaleString('en-US')} dollars`}>
        <span aria-hidden="true">$</span>
        {#each cols as d, i (cols.length - i)}
          <span class="odo__col" aria-hidden="true">
            <span class="odo__strip" class:odo__strip--run={!m.reduced} style="--d:{d}; --i:{cols.length - 1 - i}">
              {#each digits as n (n)}<span class="odo__digit">{n}</span>{/each}
            </span>
          </span>
        {/each}
      </p>
      <label class="odo__field">
        <span class="odo__hint">Type a number to roll it</span>
        <input
          class="odo__input"
          inputmode="numeric"
          autocomplete="off"
          value={typed}
          oninput={onType}
        />
      </label>
    </div>
  {/snippet}
</MotionStage>

<style>
  .odo { display: grid; gap: var(--ca-space-2); justify-items: start; }
  .odo__label { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
  .odo__value {
    display: flex;
    align-items: center;
    margin: 0;
    font-family: var(--ca-font-display);
    font-size: var(--ca-font-size-display-2);
    font-weight: var(--ca-font-weight-heading);
    line-height: 1;
    font-variant-numeric: tabular-nums;
    color: var(--ca-text-primary);
  }
  /* The window shows exactly one digit tall: the strip slides behind it. */
  .odo__col { display: inline-block; block-size: 1em; overflow: hidden; }
  .odo__strip {
    display: flex;
    flex-direction: column;
    transform: translateY(calc(var(--d) * -1em));
  }
  .odo__strip--run {
    transition: transform 900ms cubic-bezier(0.2, 0.9, 0.2, 1) calc(var(--i) * 60ms);
  }
  .odo__digit { block-size: 1em; line-height: 1; text-align: center; }
  .odo__field { display: grid; gap: var(--ca-space-1); }
  .odo__hint { font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); }
  .odo__input {
    inline-size: 9rem;
    padding: var(--ca-space-2) var(--ca-space-3);
    border: 1px solid var(--ca-outline-control);
    border-radius: var(--ca-radius-md);
    background: var(--ca-surface-raised);
    color: var(--ca-text-primary);
    font: inherit;
    font-variant-numeric: tabular-nums;
  }
  .odo__input:focus-visible { outline: 2px solid var(--ca-focus-ring); outline-offset: 2px; }
</style>
#306

Count up

Called a count up (also number counter, animated counter, contagem progressiva). Use it for a stat block that scrolls into view: 0 to the total in 1400ms with an ease-out-expo curve. Ask: "Use #306" or "a count up on the customers stat".

Reference #306 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Button, MotionStage, type MotionCtx } from '@codeatlasdev/ui';

  const target = 12480;
  const DURATION = 1400; // 1400ms count, eased out

  let n = $state(target);
  let raf = 0;

  /** easeOutExpo: fast start, long settle, so the last digits tick slowly into place. */
  const ease = (t: number) => (t >= 1 ? 1 : 1 - 2 ** (-10 * t));

  function run() {
    cancelAnimationFrame(raf);
    const start = performance.now();
    const frame = (now: number) => {
      const t = Math.min(1, (now - start) / DURATION);
      n = Math.round(target * ease(t));
      if (t < 1) raf = requestAnimationFrame(frame);
    };
    n = 0;
    raf = requestAnimationFrame(frame);
  }

  const autoplay = (m: MotionCtx) => () => {
    void m.cycle;
    if (m.reduced) {
      n = target;
      return;
    }
    run();
    return () => cancelAnimationFrame(raf);
  };
</script>

<MotionStage label="Count up: a number counts from zero and eases into its total" loop={3200}>
  {#snippet children(m)}
    <div class="cu" {@attach autoplay(m)}>
      <p class="cu__label">Active customers</p>
      <!-- The final number is the accessible value; the counting digits are decoration. -->
      <p class="cu__value" role="img" aria-label={`${target.toLocaleString('en-US')} active customers`}>
        <span aria-hidden="true">{n.toLocaleString('en-US')}</span>
      </p>
      <p class="cu__delta">+18.4% since last quarter</p>
      <Button size="sm" variant="outlined" onclick={() => !m.reduced && run()}>Count again</Button>
    </div>
  {/snippet}
</MotionStage>

<style>
  .cu { display: grid; gap: var(--ca-space-2); justify-items: start; }
  .cu__label { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
  .cu__value {
    margin: 0;
    font-family: var(--ca-font-display);
    font-size: var(--ca-font-size-display-2);
    font-weight: var(--ca-font-weight-heading);
    line-height: 1;
    font-variant-numeric: tabular-nums;
    color: var(--ca-text-primary);
  }
  .cu__delta { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-brand-text); }
</style>

Loops and highlights

#303

Infinite marquee

Called an infinite marquee (also ticker, logo wall scroll, letreiro infinito). Use it for customer or press strips: a seamless 18s linear loop that pauses on hover or focus, and Back and Forward buttons nudge it. Ask: "Use #303" or "an infinite marquee that pauses on hover".

Reference #303 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Button, MotionStage } from '@codeatlasdev/ui';

  const logos = [
    { name: 'Northwind', href: '#northwind' },
    { name: 'Lumen Health', href: '#lumen' },
    { name: 'Kestrel Freight', href: '#kestrel' },
    { name: 'Orbit Pay', href: '#orbit' },
    { name: 'Fjord Energy', href: '#fjord' },
    { name: 'Atlas Bank', href: '#atlas' }
  ];

  // Takeover: the Back and Forward buttons nudge the strip by hand, 56px per press, once autoplay has stopped.
  let nudge = $state(0);
</script>

<MotionStage label="Marquee: partner names scroll endlessly and pause on hover or focus" loop={4000}>
  {#snippet children(m)}
    <div class="mq">
      <p class="mq__eyebrow" id="mq-eyebrow">Trusted by 1,200 finance teams</p>
      <div class="mq__viewport" role="group" aria-labelledby="mq-eyebrow">
        <ul class="mq__track" class:mq__track--run={m.playing && !m.reduced} style="--nudge:{nudge}px">
          {#each logos as l (l.name)}
            <li><a class="mq__item" href={l.href}>{l.name}</a></li>
          {/each}
          <!-- Second copy closes the loop: the track moves -50% and lands on an identical frame. -->
          {#each logos as l (l.name)}
            <li aria-hidden="true"><a class="mq__item" href={l.href} tabindex="-1">{l.name}</a></li>
          {/each}
        </ul>
      </div>
      <div class="mq__bar">
        <p class="mq__hint">Hover or focus a name to pause.</p>
        <Button size="sm" variant="ghost" onclick={() => (nudge += 56)}>Back</Button>
        <Button size="sm" variant="ghost" onclick={() => (nudge -= 56)}>Forward</Button>
      </div>
    </div>
  {/snippet}
</MotionStage>

<style>
  .mq { display: grid; gap: var(--ca-space-3); inline-size: 100%; max-width: 32rem; }
  .mq__bar { display: flex; align-items: center; gap: var(--ca-space-2); }
  .mq__bar .mq__hint { flex: 1; }
  .mq__eyebrow, .mq__hint { margin: 0; font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); }
  .mq__viewport {
    overflow: hidden;
    border-block: 1px solid var(--ca-outline-subtle);
    mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
  }
  .mq__track {
    display: flex;
    inline-size: max-content;
    gap: var(--ca-space-8);
    margin: 0;
    padding: var(--ca-space-3) var(--ca-space-4);
    list-style: none;
    transform: translateX(var(--nudge));
    transition: transform 240ms cubic-bezier(0.2, 0, 0, 1);
  }
  .mq__track--run { animation: mq-slide 18s linear infinite; transition: none; }
  /* Hover or keyboard focus anywhere in the strip freezes it so a name can be read or clicked. */
  .mq__viewport:hover .mq__track,
  .mq__viewport:focus-within .mq__track { animation-play-state: paused; }
  .mq__item {
    font-family: var(--ca-font-display);
    font-size: var(--ca-font-size-h5);
    font-weight: var(--ca-font-weight-heading);
    color: var(--ca-text-secondary);
    text-decoration: none;
    white-space: nowrap;
  }
  .mq__item:hover, .mq__item:focus-visible { color: var(--ca-brand-text); }
  .mq__item:focus-visible { outline: 2px solid var(--ca-focus-ring); outline-offset: 4px; border-radius: var(--ca-radius-sm); }
  @keyframes mq-slide {
    from { transform: translateX(var(--nudge)); }
    to { transform: translateX(calc(var(--nudge) - 50%)); }
  }
</style>
#305

Shine sweep

Called a shine sweep (also gradient shimmer, text shine, brilho em texto). Use it for one hero keyword or a premium heading: a light band crosses a text-clipped gradient in 2200ms. Move the pointer to steer it. Ask: "Use #305" or "a shine sweep on the heading".

Reference #305 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { MotionStage } from '@codeatlasdev/ui';

  // Takeover: the pointer (or arrow keys on the focused heading) steers the highlight, 0-100%.
  let x = $state(50);
  function onMove(e: PointerEvent) {
    const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
    x = Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100));
  }
  function onKey(e: KeyboardEvent) {
    if (e.key === 'ArrowRight') x = Math.min(100, x + 10);
    else if (e.key === 'ArrowLeft') x = Math.max(0, x - 10);
  }
</script>

<MotionStage label="Shine sweep: a highlight glides across a gradient heading" loop={3200}>
  {#snippet children(m)}
    <div class="shine">
      <h3 class="shine__heading">
        <button
          type="button"
          class="shine__title"
          class:shine__title--run={m.playing && !m.reduced}
          class:shine__title--manual={!m.playing && !m.reduced}
          style="--x:{x}%"
          onpointermove={onMove}
          onkeydown={onKey}
        >
          Ship the quarter, not the spreadsheet
        </button>
      </h3>
      <p class="shine__hint">Move the pointer, or focus the heading and use the arrow keys.</p>
    </div>
  {/snippet}
</MotionStage>

<style>
  .shine { display: grid; gap: var(--ca-space-3); max-width: 28rem; }
  .shine__heading { margin: 0; }
  .shine__title {
    all: unset;
    display: block;
    cursor: pointer;
    font-family: var(--ca-font-display);
    font-size: var(--ca-font-size-h2);
    font-weight: var(--ca-font-weight-heading);
    line-height: 1.1;
    /* Base color stays in the gradient's outer stops: the highlight is the light band over it. */
    background-image: linear-gradient(
      100deg,
      var(--ca-brand-text) 0%,
      var(--ca-brand-text) 38%,
      var(--ca-text-primary) 50%,
      var(--ca-brand-text) 62%,
      var(--ca-brand-text) 100%
    );
    background-size: 250% 100%;
    background-position: 100% 0;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    -webkit-text-fill-color: transparent;
    outline-offset: 6px;
    box-sizing: border-box;
  }
  .shine__title:focus-visible { outline: 2px solid var(--ca-focus-ring); border-radius: var(--ca-radius-sm); }
  .shine__title--run { animation: shine-sweep 2200ms cubic-bezier(0.4, 0, 0.2, 1) both; }
  .shine__title--manual { background-position: var(--x) 0; }
  .shine__hint { margin: 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
  @keyframes shine-sweep {
    from { background-position: 100% 0; }
    to { background-position: 0% 0; }
  }
  @media (forced-colors: active) {
    .shine__title { background: none; color: CanvasText; -webkit-text-fill-color: currentColor; }
  }
</style>