Zum Inhalt springen
UI Kit
Accent

Wort für Wort

Wörter steigen nacheinander auf, sobald die Zeile ins Bild kommt, oder leuchten beim Lesen auf. Screenreader hören den Satz einmal, ohne JavaScript steht er vollständig da.

Eine von 5 Components in Text Effects

3 Variants

Variants

Auftaktzeile

Display-Größe, steigt aus einer weichen Unschärfe auf, eine Wendung im Akzent.

Unschärfe

Jedes Wort wird an seinem Platz scharf, nichts bewegt sich. Langsamer und leiser.

Beim Lesen

Ein Einleitungsabsatz leuchtet Wort für Wort mit dem Scrollen auf, die Leseposition trägt das Licht.

Details

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

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

Props

Die Schnittstelle WordRevealProps aus components/ui/word-reveal.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • textPflicht
    string

    The full sentence. It is split at spaces, punctuation stays with its word.

  • asoptional
    Tag

    h1h2h3h4pspandiv

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

  • effectoptional
    WordRevealEffect

    riseblurfade

    rise: up out of a soft blur (default). blur: sharpens in place. fade: opacity only.

  • triggeroptional
    WordRevealTrigger

    loadviewin-viewhoverscroll

    load: right away. view (or in-view): when it scrolls in (default). hover: again on every hover and focus. scroll: the words light up one after another while the line moves up through the window, as you read.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that lights the words, 0 to 1. Default [0.12, 0.62].

  • staggeroptional
    number

    Milliseconds between two words. Default 70.

  • durationoptional
    number

    Milliseconds one word takes. Default 800.

  • delayoptional
    number

    Milliseconds before the first word. Default 0.

  • highlightoptional
    string

    A phrase inside text that takes the accent color, e.g. "verkaufen"

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, 214 Zeilen
components/ui/word-reveal.tsx
'use client';

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

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

export interface WordRevealProps {
  /** The full sentence. It is split at spaces, punctuation stays with its word. */
  text: string;
  /** Element to render, keeps the heading level. Default p. */
  as?: Tag;
  /** rise: up out of a soft blur (default). blur: sharpens in place. fade: opacity only. */
  effect?: WordRevealEffect;
  /**
   * load: right away. view (or in-view): when it scrolls in (default).
   * hover: again on every hover and focus. scroll: the words light up one
   * after another while the line moves up through the window, as you read.
   */
  trigger?: WordRevealTrigger;
  /** With trigger scroll: the part of the way through the window that lights the words, 0 to 1. Default [0.12, 0.62]. */
  scrollRange?: [number, number];
  /** Milliseconds between two words. Default 70. */
  stagger?: number;
  /** Milliseconds one word takes. Default 800. */
  duration?: number;
  /** Milliseconds before the first word. Default 0. */
  delay?: number;
  /** A phrase inside text that takes the accent color, e.g. "verkaufen" */
  highlight?: string;
  /** Classes for the element, e.g. the type scale text-display, text-h1, text-h2, text-lead */
  className?: string;
}

/*
 * Keyframes and states live with the component, prefixed so they never collide.
 * Scroll: every word reads its own share of --kitfx-p (written by the scroll
 * link), a soft window two words wide, so the light runs through the
 * sentence instead of switching word by word. Without the variable (server,
 * no JavaScript, reduced motion) the share is 1 and every word stands lit.
 */
const CSS = `
.kitfx-wr-w{display:inline-block;white-space:nowrap;padding-block:.1em;margin-block:-.1em}
.kitfx-wr[data-mode=play] .kitfx-wr-w{animation:var(--kitfx-wr-name) var(--kitfx-wr-dur) cubic-bezier(.2,.7,.2,1) both;animation-delay:calc(var(--kitfx-wr-delay) + var(--i) * var(--kitfx-wr-stagger))}
.kitfx-wr[data-mode=wait] .kitfx-wr-w{opacity:0}
.kitfx-wr[data-mode=scroll] .kitfx-wr-w{--t:clamp(0,(var(--kitfx-p,1) * (var(--n) + 2) - var(--i)) / 2,1);opacity:calc(.16 + .84 * var(--t));transition:opacity .25s linear,transform .25s linear,filter .25s linear}
.kitfx-wr[data-mode=scroll][data-effect=rise] .kitfx-wr-w{transform:translateY(calc((1 - var(--t)) * .16em))}
.kitfx-wr[data-mode=scroll][data-effect=blur] .kitfx-wr-w{filter:blur(calc((1 - var(--t)) * 5px))}
.kitfx-wr[data-effect=rise]{--kitfx-wr-name:kitfx-wr-rise}
.kitfx-wr[data-effect=blur]{--kitfx-wr-name:kitfx-wr-blur}
.kitfx-wr[data-effect=fade]{--kitfx-wr-name:kitfx-wr-fade}
@keyframes kitfx-wr-rise{from{opacity:0;transform:translateY(.42em);filter:blur(6px)}}
@keyframes kitfx-wr-blur{from{opacity:0;filter:blur(14px)}}
@keyframes kitfx-wr-fade{from{opacity:0}}
@media (prefers-reduced-motion:reduce){
.kitfx-wr[data-mode] .kitfx-wr-w{animation:kitfx-wr-fade .5s ease both;opacity:1;transform:none;filter:none}
}`;

interface Piece {
  text: string;
  accent: boolean;
  start: number;
}

/** Splits text into words, and each word into plain and highlighted pieces. */
function split(text: string, highlight?: string) {
  const at = highlight ? text.indexOf(highlight) : -1;
  const end = at >= 0 ? at + highlight!.length : -1;
  const words: Piece[][] = [];
  for (const m of text.matchAll(/\S+/g)) {
    const s = m.index ?? 0;
    const w = m[0];
    const cuts = [0, w.length];
    if (at >= 0) {
      if (at > s && at < s + w.length) cuts.push(at - s);
      if (end > s && end < s + w.length) cuts.push(end - s);
    }
    const sorted = [...new Set(cuts)].sort((a, b) => a - b);
    const pieces: Piece[] = [];
    for (let i = 0; i < sorted.length - 1; i++) {
      const a = s + sorted[i];
      pieces.push({ text: w.slice(sorted[i], sorted[i + 1]), accent: at >= 0 && a >= at && a < end, start: a });
    }
    words.push(pieces);
  }
  return { words, at, length: at >= 0 ? highlight!.length : 0 };
}

/**
 * One continuous accent gradient over a phrase that is split into pieces:
 * every piece shows its slice of the same gradient (by character share).
 */
function accentSlice(start: number, len: number, phraseStart: number, phraseLen: number): React.CSSProperties {
  if (len >= phraseLen) return {};
  const p = (start - phraseStart) / (phraseLen - len);
  return { backgroundSize: `${round((phraseLen / len) * 100)}% 100%`, backgroundPosition: `${round(p * 100)}% 0` };
}

/**
 * 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]) {
  const [start, end] = range;
  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));
    };
    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]);
}

/**
 * Plays a CSS entrance. It is on from the first paint, so a line that is
 * already on screen animates without waiting for JavaScript. A line further
 * down is held back after hydration and plays when it scrolls in.
 */
function usePlay<T extends HTMLElement>(trigger: WordRevealTrigger) {
  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 };
}

/**
 * Words rise one after another. Screen readers get the full sentence once,
 * the animated words are hidden from them. Without JavaScript and with
 * reduced motion the sentence stands complete. Words never break apart.
 * With trigger="scroll" the words light up while you read.
 *   <WordReveal as="h1" className="text-display" text="Websites, die verkaufen" highlight="verkaufen" />
 */
export function WordReveal({ text, as: Tag = 'p', effect = 'rise', trigger = 'view', scrollRange = [0.12, 0.62], stagger = 70, duration = 800, delay = 0, highlight, className }: WordRevealProps) {
  const { ref, run, mode, replay } = usePlay<HTMLElement>(trigger);
  useScrollLink(ref, trigger === 'scroll', scrollRange);
  const { words, at, length } = split(text.trim(), highlight);
  const vars = { '--kitfx-wr-stagger': `${stagger}ms`, '--kitfx-wr-dur': `${duration}ms`, '--kitfx-wr-delay': `${delay}ms`, '--n': words.length } as React.CSSProperties;
  const hover = trigger === 'hover' ? { onPointerEnter: replay, onFocus: replay } : {};

  return (
    <Tag ref={ref as never} className={cn('kitfx-wr', 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((pieces, i) => (
          <span key={i}>
            {i > 0 && ' '}
            <span className="kitfx-wr-w" style={{ '--i': i } as React.CSSProperties}>
              {pieces.map((p) =>
                p.accent ? (
                  <span key={p.start} className="kit-accent-text" style={accentSlice(p.start, p.text.length, at, length)}>
                    {p.text}
                  </span>
                ) : (
                  p.text
                ),
              )}
            </span>
          </span>
        ))}
      </span>
    </Tag>
  );
}

Häufige Fragen

Wie installiere ich Wort für Wort?

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

Welche Varianten hat Wort für Wort?

3 Varianten, Auftaktzeile, Unschärfe und Beim Lesen.

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

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert