Zum Inhalt springen
UI Kit
Accent

Wechselnde Wörter

Ein Wort im Satz wechselt, der Platz gleitet auf die Breite des nächsten Worts, nichts springt. Pausiert außerhalb des Bildes und bei Hover.

Eine von 5 Components in Textakzente

3 Variants

Variants

Schieben

Mitten in der Auftaktzeile schiebt sich das Wort nach oben weg, das nächste steigt nach.

Pille und Unschärfe

Eine weiche Akzentpille hält das Wort, ihre Breite folgt jedem Wort, die Wörter blenden durch eine Unschärfe.

Kippen beim Scrollen

Die Scrollposition wählt das Wort, jedes kippt über die waagerechte Achse. Linksbündig am Zeilenende.

Details

Was Wechselnde Wörter mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle RotatingWordsProps aus components/ui/rotating-words.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • wordsPflicht
    string[]

    The words that take turns, the first one is the one without JavaScript

  • effectoptional
    RotatingWordsEffect

    slideblurflip

    slide: up and out, up and in (default). blur: crossfade through a blur. flip: a short turn over the x axis.

  • intervaloptional
    number

    Milliseconds a word stays. Default 2400.

  • toneoptional
    'inherit' | 'accent'

    inherit: the text color (default). accent: kit-accent-text.

  • pilloptional
    boolean

    A soft accent pill behind the word, its width follows the word

  • triggeroptional
    'load' | 'view' | 'in-view' | 'scroll'

    view (or in-view, default) and load: the words take turns on a clock, paused off screen. scroll: the scroll position picks the word, every word gets its share of the way through the window and the first one returns at the end.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that runs through the words, 0 to 1. Default [0.04, 0.6].

  • loopoptional
    boolean

    Start over after the last word. Default true. false stops on the last word.

  • suffixoptional
    string

    Text glued right after the word that must not wrap away from it, e.g. a comma

  • labeloptional
    string

    What screen readers hear in place of the words. Default: the words joined with commas.

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/rotating-words.tsx
'use client';

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

export type RotatingWordsEffect = 'slide' | 'blur' | 'flip';

export interface RotatingWordsProps {
  /** The words that take turns, the first one is the one without JavaScript */
  words: string[];
  /** slide: up and out, up and in (default). blur: crossfade through a blur. flip: a short turn over the x axis. */
  effect?: RotatingWordsEffect;
  /** Milliseconds a word stays. Default 2400. */
  interval?: number;
  /** inherit: the text color (default). accent: kit-accent-text. */
  tone?: 'inherit' | 'accent';
  /** A soft accent pill behind the word, its width follows the word */
  pill?: boolean;
  /**
   * view (or in-view, default) and load: the words take turns on a clock,
   * paused off screen. scroll: the scroll position picks the word, every word
   * gets its share of the way through the window and the first one returns
   * at the end.
   */
  trigger?: 'load' | 'view' | 'in-view' | 'scroll';
  /** With trigger scroll: the part of the way through the window that runs through the words, 0 to 1. Default [0.04, 0.6]. */
  scrollRange?: [number, number];
  /** Start over after the last word. Default true. false stops on the last word. */
  loop?: boolean;
  /** Text glued right after the word that must not wrap away from it, e.g. a comma */
  suffix?: string;
  /** What screen readers hear in place of the words. Default: the words joined with commas. */
  label?: string;
  className?: string;
}

const CSS = `
.kitfx-rw{white-space:nowrap}
.kitfx-rw-slot{display:inline-grid;justify-items:start;align-items:baseline;box-sizing:content-box;overflow:clip;overflow-clip-margin:.25em;transition:width .55s cubic-bezier(.3,.7,.2,1);text-align:start}
.kitfx-rw[data-pill] .kitfx-rw-slot{padding-inline:.28em;margin-inline:.04em;border-radius:.24em;background:color-mix(in oklch,var(--primary) 13%,transparent);box-shadow:inset 0 0 0 1px color-mix(in oklch,var(--primary) 18%,transparent);overflow-clip-margin:0}
.kitfx-rw-word{grid-area:1/1;padding-block:.1em;margin-block:-.1em;transition:transform .6s cubic-bezier(.3,.7,.2,1),opacity .45s ease,filter .5s ease}
.kitfx-rw-word:is([data-state=out],[data-state=next]){opacity:0;pointer-events:none}
.kitfx-rw:not([data-ready]) .kitfx-rw-word:not([data-state=in]){position:absolute;left:0;top:0}
.kitfx-rw[data-effect=slide] .kitfx-rw-word[data-state=out]{transform:translateY(-.75em)}
.kitfx-rw[data-effect=slide] .kitfx-rw-word[data-state=next]{transform:translateY(.75em)}
.kitfx-rw[data-effect=blur] .kitfx-rw-word:is([data-state=out],[data-state=next]){filter:blur(8px)}
.kitfx-rw[data-effect=blur] .kitfx-rw-word[data-state=next]{transform:scale(.96)}
.kitfx-rw[data-effect=flip] .kitfx-rw-slot{perspective:4em}
.kitfx-rw[data-effect=flip] .kitfx-rw-word{transform-origin:50% 50% -.4em;backface-visibility:hidden}
.kitfx-rw[data-effect=flip] .kitfx-rw-word[data-state=out]{transform:rotateX(80deg)}
.kitfx-rw[data-effect=flip] .kitfx-rw-word[data-state=next]{transform:rotateX(-80deg)}
.kitfx-rw-word[data-state=next]{transition:none}
@media (prefers-reduced-motion:reduce){.kitfx-rw-slot,.kitfx-rw-word{transition:none}}`;

/**
 * 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]);
}

/** Measuring before paint in the browser, a no-op warning free effect on the server */
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/**
 * One word in a sentence takes turns with others. The slot animates its
 * width to the next word, so the sentence glides instead of jumping. The
 * cycle pauses off screen and while the pointer rests on it. Screen readers
 * hear all words once; without JavaScript and with reduced motion the first
 * word stands still.
 *   <h1>Websites for <RotatingWords words={['law firms', 'cafés', 'studios']} /></h1>
 */
export function RotatingWords({ words, effect = 'slide', interval = 2400, trigger = 'view', scrollRange = [0.04, 0.6], tone = 'inherit', pill, loop = true, suffix, label, className }: RotatingWordsProps) {
  const root = useRef<HTMLSpanElement>(null);
  const refs = useRef<(HTMLSpanElement | null)[]>([]);
  const [index, setIndex] = useState(0);
  const [prev, setPrev] = useState<number | null>(null);
  const [widths, setWidths] = useState<number[] | null>(null);
  const [onScreen, setOnScreen] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [still, setStill] = useState(true);
  const scroll = trigger === 'scroll';
  const shown = useRef(0);
  shown.current = index;
  // Scroll: the position picks the word, the first one stands again at the end
  useScrollLink(root, scroll && words.length > 1, scrollRange, (p) => {
    const next = p >= 1 ? 0 : Math.min(words.length - 1, Math.floor(p * words.length));
    if (next === shown.current) return;
    setPrev(shown.current);
    setIndex(next);
    shown.current = next;
  });

  // Measure every word, again when fonts load or the size changes
  useIsoLayoutEffect(() => {
    // offsetWidth ignores transforms, so a scaled or flipped word never measures too narrow
    const measure = () => setWidths(refs.current.map((el) => (el ? round(el.offsetWidth, 1) : 0)));
    measure();
    const ro = new ResizeObserver(measure);
    if (root.current) ro.observe(root.current);
    document.fonts?.ready.then(measure).catch(() => {});
    return () => ro.disconnect();
  }, [words.join('|')]);

  useEffect(() => {
    if (prefersReducedMotion() || words.length < 2 || scroll) return;
    setStill(false);
    const el = root.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setOnScreen(!!e?.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, [words.length, scroll]);

  useEffect(() => {
    if (still || !onScreen || hovered) return;
    if (!loop && index >= words.length - 1) return;
    const timer = window.setTimeout(() => {
      setPrev(index);
      setIndex((index + 1) % words.length);
    }, interval);
    return () => window.clearTimeout(timer);
  }, [still, onScreen, hovered, index, loop, interval, words.length]);

  const width = widths?.[index];
  return (
    <span
      ref={root}
      className={cn('kitfx-rw', className)}
      data-effect={effect}
      data-pill={pill ? '' : undefined}
      data-ready={widths ? '' : undefined}
      onPointerEnter={() => setHovered(true)}
      onPointerLeave={() => setHovered(false)}
    >
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <span className="sr-only">{label ?? words.join(', ')}</span>
      <span aria-hidden data-clip-ok className="kitfx-rw-slot" style={width ? { width: `${width}px` } : undefined}>
        {words.map((w, i) => (
          <span
            key={i}
            ref={(el) => {
              refs.current[i] = el;
            }}
            className={cn('kitfx-rw-word', tone === 'accent' && 'kit-accent-text')}
            data-state={i === index ? 'in' : i === prev ? 'out' : 'next'}
          >
            {w}
          </span>
        ))}
      </span>
      {suffix}
    </span>
  );
}

Häufige Fragen

Wie installiere ich Wechselnde Wörter?

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

Welche Varianten hat Wechselnde Wörter?

3 Varianten, Schieben, Pille und Unschärfe und Kippen beim Scrollen.

Kann ein KI-Assistent Wechselnde Wörter einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Wechselnde Wörter mit aiw_components und installieren sie mit aiw_add @ai-website/rotating-words.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert