Zum Inhalt springen
UI Kit
Accent

Buchstabe für Buchstabe

Buchstaben treten nacheinander auf, Wörter bleiben ganz und brechen nie um. Screenreader hören den Text einmal.

Eine von 5 Components in Text Effects

3 Variants

Variants

Aufsteigen

Buchstaben steigen an ihren Platz, der Ortsname trägt den Akzent.

Unschärfe beim Scrollen

Buchstaben werden mit der Scrollposition an ihrem Platz scharf, ruhig genug für einen ernsten Ton.

Wachsen

Buchstaben wachsen aus etwas kleiner heran, schnell und hell, linksbündig.

Details

Was Buchstabe für Buchstabe mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Text Effects
Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/letter-stagger.tsx
Bringt mit
utilskit
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle LetterStaggerProps aus components/ui/letter-stagger.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • textPflicht
    string

    The full text. Words are kept whole, letters only move inside them.

  • asoptional
    Tag

    h1h2h3h4pspandiv

    Element to render, keeps the heading level. Default p.

  • effectoptional
    LetterStaggerEffect

    risedropblurscale

    rise: up from below (default). drop: down from above. blur: sharpens in place. scale: grows from small.

  • triggeroptional
    LetterStaggerTrigger

    loadviewin-viewhoverscroll

    load: right away. view (or in-view): when it scrolls in (default). hover: again on every hover and focus. scroll: the letters arrive with the scroll position and leave again when you scroll back up.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that brings the letters in, 0 to 1. Default [0.06, 0.46].

  • staggeroptional
    number

    Milliseconds between two letters. Default 32.

  • durationoptional
    number

    Milliseconds one letter takes. Default 700.

  • delayoptional
    number

    Milliseconds before the first letter. Default 0.

  • highlightoptional
    string

    A phrase inside text that takes the accent color

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 191 Zeilen
components/ui/letter-stagger.tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';

export type LetterStaggerEffect = 'rise' | 'drop' | 'blur' | 'scale';
/** in-view is the same as view */
export type LetterStaggerTrigger = 'load' | 'view' | 'in-view' | 'hover' | 'scroll';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';

export interface LetterStaggerProps {
  /** The full text. Words are kept whole, letters only move inside them. */
  text: string;
  /** Element to render, keeps the heading level. Default p. */
  as?: Tag;
  /** rise: up from below (default). drop: down from above. blur: sharpens in place. scale: grows from small. */
  effect?: LetterStaggerEffect;
  /**
   * load: right away. view (or in-view): when it scrolls in (default).
   * hover: again on every hover and focus. scroll: the letters arrive with
   * the scroll position and leave again when you scroll back up.
   */
  trigger?: LetterStaggerTrigger;
  /** With trigger scroll: the part of the way through the window that brings the letters in, 0 to 1. Default [0.06, 0.46]. */
  scrollRange?: [number, number];
  /** Milliseconds between two letters. Default 32. */
  stagger?: number;
  /** Milliseconds one letter takes. Default 700. */
  duration?: number;
  /** Milliseconds before the first letter. Default 0. */
  delay?: number;
  /** A phrase inside text that takes the accent color */
  highlight?: string;
  /** Classes for the element, e.g. text-display, text-h1, text-h2 */
  className?: string;
}

const CSS = `
.kitfx-ls-w{white-space:nowrap}
.kitfx-ls-l{display:inline-block;padding-block:.1em;margin-block:-.1em}
.kitfx-ls[data-mode=play] .kitfx-ls-l{animation:var(--kitfx-ls-name) var(--kitfx-ls-dur) cubic-bezier(.2,.75,.25,1) both;animation-delay:calc(var(--kitfx-ls-delay) + var(--i) * var(--kitfx-ls-stagger))}
.kitfx-ls[data-mode=wait] .kitfx-ls-l{opacity:0}
.kitfx-ls[data-mode=scroll] .kitfx-ls-l{--t:clamp(0,(var(--kitfx-p,1) * (var(--n) + var(--w)) - var(--i)) / var(--w),1);opacity:var(--t);transition:opacity .2s linear,transform .2s linear,filter .2s linear}
.kitfx-ls[data-mode=scroll][data-effect=rise] .kitfx-ls-l{transform:translateY(calc((1 - var(--t)) * .55em))}
.kitfx-ls[data-mode=scroll][data-effect=drop] .kitfx-ls-l{transform:translateY(calc((var(--t) - 1) * .5em))}
.kitfx-ls[data-mode=scroll][data-effect=blur] .kitfx-ls-l{filter:blur(calc((1 - var(--t)) * 8px))}
.kitfx-ls[data-mode=scroll][data-effect=scale] .kitfx-ls-l{transform:scale(calc(.7 + .3 * var(--t)))}
.kitfx-ls[data-effect=rise]{--kitfx-ls-name:kitfx-ls-rise}
.kitfx-ls[data-effect=drop]{--kitfx-ls-name:kitfx-ls-drop}
.kitfx-ls[data-effect=blur]{--kitfx-ls-name:kitfx-ls-blur}
.kitfx-ls[data-effect=scale]{--kitfx-ls-name:kitfx-ls-scale}
@keyframes kitfx-ls-rise{from{opacity:0;transform:translateY(.55em)}}
@keyframes kitfx-ls-drop{from{opacity:0;transform:translateY(-.5em)}}
@keyframes kitfx-ls-blur{from{opacity:0;filter:blur(8px)}}
@keyframes kitfx-ls-scale{from{opacity:0;transform:scale(.7)}}
@keyframes kitfx-ls-fade{from{opacity:0}}
@media (prefers-reduced-motion:reduce){.kitfx-ls[data-mode] .kitfx-ls-l{animation:kitfx-ls-fade .5s ease both;opacity:1;transform:none;filter:none}}`;

/** Plays a CSS entrance from the first paint, holds lines further down until they scroll in. */
function usePlay<T extends HTMLElement>(trigger: LetterStaggerTrigger) {
  const ref = useRef<T>(null);
  const [run, setRun] = useState(0);
  const [mode, setMode] = useState<'play' | 'wait' | 'idle' | 'scroll'>(trigger === 'hover' ? 'idle' : trigger === 'scroll' ? 'scroll' : 'play');
  useEffect(() => {
    const el = ref.current;
    if (!el || (trigger !== 'view' && trigger !== 'in-view') || prefersReducedMotion()) return;
    if (el.getBoundingClientRect().top < window.innerHeight * 0.9) return;
    setMode('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setMode('play');
        setRun((r) => r + 1);
        io.disconnect();
      },
      { rootMargin: '0px 0px -10% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [trigger]);
  const replay = useCallback(() => {
    if (prefersReducedMotion()) return;
    setMode('play');
    setRun((r) => r + 1);
  }, []);
  return { ref, run, mode, replay };
}

/**
 * Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
 * travelled `start` of its way through the window, 1 at `end` (0 is its top
 * at the bottom edge, 1 its bottom at the top edge). The value is scaled to
 * the part of that way the page can still scroll, so a line at the end of a
 * short page still finishes. Never with reduced motion, then the CSS
 * fallback 1 keeps the final state.
 */
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
  const [start, end] = range;
  const listener = useRef(onChange);
  listener.current = onChange;
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const span = Math.max(0.0001, end - start);
    const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
      const most = at(r.top - rest, r.height, vh);
      const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
      el.style.setProperty('--kitfx-p', p.toFixed(4));
      listener.current?.(p);
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on, start, end]);
}

/** Graphemes, so an emoji or an accent made of two code points stays one letter. */
function letters(word: string): string[] {
  const Seg = (Intl as unknown as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
  if (Seg) return Array.from(new Seg(undefined, { granularity: 'grapheme' }).segment(word), (s) => s.segment);
  return Array.from(word);
}

/**
 * Letters enter one after another. Each word stays one unit that never
 * breaks across lines, punctuation stays with its word. Screen readers get
 * the text once, the letters are hidden from them. Without JavaScript and
 * with reduced motion the text stands complete.
 *   <LetterStagger as="h2" className="text-display" text="Café Mira" />
 */
export function LetterStagger({ text, as: Tag = 'p', effect = 'rise', trigger = 'view', scrollRange = [0.06, 0.46], stagger = 32, duration = 700, delay = 0, highlight, className }: LetterStaggerProps) {
  const { ref, run, mode, replay } = usePlay<HTMLElement>(trigger);
  useScrollLink(ref, trigger === 'scroll', scrollRange);
  const clean = text.trim();
  const at = highlight ? clean.indexOf(highlight) : -1;
  const end = at >= 0 ? at + highlight!.length : -1;
  const hover = trigger === 'hover' ? { onPointerEnter: replay, onFocus: replay } : {};

  let index = 0;
  const words = Array.from(clean.matchAll(/\S+/g), (m) => {
    let pos = m.index ?? 0;
    return letters(m[0]).map((ch) => {
      const p = pos;
      pos += ch.length;
      return { ch, i: index++, accent: at >= 0 && p >= at && p < end, p };
    });
  });
  const vars = { '--kitfx-ls-stagger': `${stagger}ms`, '--kitfx-ls-dur': `${duration}ms`, '--kitfx-ls-delay': `${delay}ms`, '--n': index, '--w': Math.max(3, Math.round(index * 0.2)) } as React.CSSProperties;
  // The accent is one gradient over the whole phrase, every letter shows its slice of it.
  const accentStyle = (p: number): React.CSSProperties => {
    const n = end - at;
    if (n <= 1) return {};
    return { backgroundSize: `${round(n * 100)}% 100%`, backgroundPosition: `${round(((p - at) / (n - 1)) * 100)}% 0` };
  };

  return (
    <Tag ref={ref as never} className={cn('kitfx-ls', className)} data-mode={mode} data-effect={effect} style={vars} {...hover}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <span className="sr-only">{text}</span>
      <span aria-hidden key={run}>
        {words.map((w, wi) => (
          <span key={wi}>
            {wi > 0 && ' '}
            <span className="kitfx-ls-w">
              {w.map((x) => (
                <span key={x.i} className={cn('kitfx-ls-l', x.accent && 'kit-accent-text')} style={{ '--i': x.i, ...(x.accent ? accentStyle(x.p) : {}) } as React.CSSProperties}>
                  {x.ch}
                </span>
              ))}
            </span>
          </span>
        ))}
      </span>
    </Tag>
  );
}

Häufige Fragen

Wie installiere ich Buchstabe für Buchstabe?

Mit aiw add @ai-website/letter-stagger. Der Befehl legt die Datei components/ui/letter-stagger.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Buchstabe für Buchstabe?

3 Varianten, Aufsteigen, Unschärfe beim Scrollen und Wachsen.

Kann ein KI-Assistent Buchstabe für Buchstabe einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Buchstabe für Buchstabe mit aiw_components und installieren sie mit aiw_add @ai-website/letter-stagger.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert