Zum Inhalt springen
UI Kit
Accent

Entschlüsseln

Buchstaben wirbeln und lösen sich in den Text auf, wenn er ins Bild kommt und erneut bei Hover. Mit festem Startwert, jeder Besuch sieht gleich aus, nichts verschiebt sich.

Eine von 5 Components in Text Effects

3 Variants

Variants

Überschrift

Eine große Zeile löst sich von links nach rechts auf, offene Buchstaben im Akzent.

Code beim Scrollen

Ein Gutscheincode, der sich mit der Scrollposition Buchstabe für Buchstabe entschlüsselt, Tabellenziffern, nur Buchstaben derselben Schreibweise.

Details

Was Entschlüsseln mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle ScrambleProps aus components/ui/scramble.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • textPflicht
    string

    The text it resolves to

  • asoptional
    Tag

    h1h2h3h4pspandiv

    Element to render. Default span.

  • triggeroptional
    ScrambleTrigger

    loadviewin-viewhoverscroll

    load: right away. view (or in-view): when it scrolls in (default). hover: only on hover and focus. scroll: the letters resolve from left to right with the scroll position and scramble again when you scroll back.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that resolves the text, 0 to 1. Default [0.05, 0.5].

  • replayOnHoveroptional
    boolean

    Scramble again on every hover and focus, also after view or load (not with scroll). Default true.

  • durationoptional
    number

    Milliseconds until the last letter has resolved. Default 1100.

  • delayoptional
    number

    Milliseconds before it starts. Default 0.

  • charactersoptional
    string

    Characters to scramble with. Default: letters of the same case, digits for digits.

  • seedoptional
    number

    Seed for the sequence of characters, the same seed gives the same scramble everywhere

  • toneoptional
    'accent' | 'inherit'

    Unresolved letters in the accent color (default) or in the text 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, 264 Zeilen
components/ui/scramble.tsx
'use client';

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

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

export interface ScrambleProps {
  /** The text it resolves to */
  text: string;
  /** Element to render. Default span. */
  as?: Tag;
  /**
   * load: right away. view (or in-view): when it scrolls in (default).
   * hover: only on hover and focus. scroll: the letters resolve from left to
   * right with the scroll position and scramble again when you scroll back.
   */
  trigger?: ScrambleTrigger;
  /** With trigger scroll: the part of the way through the window that resolves the text, 0 to 1. Default [0.05, 0.5]. */
  scrollRange?: [number, number];
  /** Scramble again on every hover and focus, also after view or load (not with scroll). Default true. */
  replayOnHover?: boolean;
  /** Milliseconds until the last letter has resolved. Default 1100. */
  duration?: number;
  /** Milliseconds before it starts. Default 0. */
  delay?: number;
  /** Characters to scramble with. Default: letters of the same case, digits for digits. */
  characters?: string;
  /** Seed for the sequence of characters, the same seed gives the same scramble everywhere */
  seed?: number;
  /** Unresolved letters in the accent color (default) or in the text color */
  tone?: 'accent' | 'inherit';
  className?: string;
}

const UPPER = 'ABCDEFGHJKLMNOPRSTUVWXYZ';
const LOWER = 'abcdefghijkmnoprstuvwxyz';
const DIGIT = '0123456789';

/*
 * The real text always lies in the box and gives it its size; while the
 * scramble runs it is invisible and the scrambled copy lies on top. Every
 * letter keeps the slot of its final letter, words stay whole, so nothing
 * moves and nothing wraps differently. Screen readers read a separate
 * copy of the text that never changes.
 */
const CSS = `
.kitfx-sc{position:relative;display:inline-block;max-width:100%}
div.kitfx-sc,p.kitfx-sc,h1.kitfx-sc,h2.kitfx-sc,h3.kitfx-sc,h4.kitfx-sc{display:block}
.kitfx-sc[data-run] .kitfx-sc-text{visibility:hidden}
.kitfx-sc-run{position:absolute;inset:0}
.kitfx-sc-w{white-space:nowrap}
.kitfx-sc-c{display:inline-grid;justify-items:center}
.kitfx-sc-c>*{grid-area:1/1}
.kitfx-sc-c>.kitfx-sc-f{visibility:hidden}
.kitfx-sc-c[data-open]>.kitfx-sc-g{color:var(--kitfx-sc-ink);-webkit-text-fill-color:var(--kitfx-sc-ink)}
.kitfx-sc[data-tone=accent]{--kitfx-sc-ink:color-mix(in oklch,var(--primary) 75%,currentColor)}
.kitfx-sc[data-tone=inherit]{--kitfx-sc-ink:color-mix(in oklch,currentColor 55%,transparent)}`;

/** Small, fast, seeded random numbers (mulberry32). Never used during render on the server. */
function seeded(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const hash = (s: string) => {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
  return h >>> 0;
};

const poolFor = (ch: string, custom?: string) => {
  if (custom) return custom;
  if (/\d/.test(ch)) return DIGIT;
  if (ch !== ch.toLowerCase()) return UPPER;
  if (ch !== ch.toUpperCase()) return LOWER;
  return '';
};

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

/**
 * Letters scramble and resolve one after another into the text, when it
 * scrolls in and again on hover. The sequence is seeded, so it looks the
 * same on every visit. Screen readers only read the real text; without
 * JavaScript and with reduced motion the text simply stands there.
 *   <Scramble as="h2" className="text-h1" text="Atlas Studio" />
 */
export function Scramble({ text, as: Tag = 'span', trigger = 'view', scrollRange = [0.05, 0.5], replayOnHover = true, duration = 1100, delay = 0, characters, seed, tone = 'accent', className }: ScrambleProps) {
  const ref = useRef<HTMLElement>(null);
  const raf = useRef(0);
  const runs = useRef(0);
  const [frame, setFrame] = useState<string[] | null>(null);
  const [open, setOpen] = useState<boolean[]>([]);

  const play = useCallback(() => {
    if (prefersReducedMotion()) return;
    cancelAnimationFrame(raf.current);
    const chars = Array.from(text);
    const rand = seeded((seed ?? hash(text)) + runs.current++ * 7919);
    // When each letter resolves: left to right, with a little seeded unevenness
    const n = Math.max(1, chars.length - 1);
    const at = chars.map((_, i) => (i / n) * duration * 0.78 + rand() * duration * 0.22);
    const start = performance.now() + delay;
    let lastSwap = 0;
    let glyphs = chars.map((c) => c);
    const tick = (now: number) => {
      const t = now - start;
      if (t >= duration) {
        setFrame(null);
        return;
      }
      if (now - lastSwap > 72) {
        lastSwap = now;
        glyphs = chars.map((c, i) => {
          const pool = poolFor(c, characters);
          if (!pool || t >= at[i]!) return c;
          return pool[Math.floor(rand() * pool.length)]!;
        });
        setFrame(glyphs);
        setOpen(chars.map((c, i) => !!poolFor(c, characters) && t < at[i]!));
      }
      raf.current = requestAnimationFrame(tick);
    };
    setFrame(chars);
    setOpen(chars.map(() => false));
    raf.current = requestAnimationFrame(tick);
  }, [text, duration, delay, characters, seed]);

  // Scroll: letters left of the position are resolved, the others turn over now and then
  const scroll = trigger === 'scroll';
  const swap = useRef<{ at: number; glyphs: string[]; rand: () => number } | null>(null);
  useScrollLink(ref, scroll, scrollRange, (p) => {
    if (p >= 1) {
      setFrame(null);
      return;
    }
    const chars = Array.from(text);
    const now = performance.now();
    const s = (swap.current ??= { at: 0, glyphs: chars, rand: seeded(seed ?? hash(text)) });
    const fresh = now - s.at > 72;
    if (fresh) s.at = now;
    const done = Math.floor(p * (chars.length + 1));
    s.glyphs = chars.map((c, i) => {
      const pool = poolFor(c, characters);
      if (!pool || i < done) return c;
      return fresh || s.glyphs[i] === undefined ? pool[Math.floor(s.rand() * pool.length)]! : s.glyphs[i]!;
    });
    setFrame(s.glyphs);
    setOpen(chars.map((c, i) => !!poolFor(c, characters) && i >= done));
  });

  useEffect(() => {
    const el = ref.current;
    if (!el || trigger === 'hover' || scroll || prefersReducedMotion()) return;
    if (trigger === 'load') {
      play();
      return () => cancelAnimationFrame(raf.current);
    }
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        io.disconnect();
        play();
      },
      { rootMargin: '0px 0px -10% 0px' },
    );
    io.observe(el);
    return () => {
      io.disconnect();
      cancelAnimationFrame(raf.current);
    };
  }, [trigger, scroll, play]);

  const hover = !scroll && (trigger === 'hover' || replayOnHover) ? { onPointerEnter: play, onFocus: play } : {};

  // The scrambled copy: words as unbreakable groups, every letter in the slot of its final letter
  let k = 0;
  const words = frame
    ? text.split(/(\s+)/).map((part, wi) => {
        if (/^\s+$/.test(part)) {
          k += Array.from(part).length;
          return part;
        }
        return (
          <span key={wi} className="kitfx-sc-w">
            {Array.from(part).map((c) => {
              const i = k++;
              return (
                <span key={i} className="kitfx-sc-c" data-open={open[i] ? '' : undefined}>
                  <span className="kitfx-sc-f">{c}</span>
                  <span className="kitfx-sc-g">{frame[i] ?? c}</span>
                </span>
              );
            })}
          </span>
        );
      })
    : null;

  return (
    <Tag ref={ref as never} className={cn('kitfx-sc', className)} data-run={frame ? '' : undefined} data-tone={tone} {...hover}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <span className="sr-only">{text}</span>
      <span aria-hidden className="kitfx-sc-text">
        {text}
      </span>
      {words && (
        <span aria-hidden className="kitfx-sc-run">
          {words}
        </span>
      )}
    </Tag>
  );
}

Häufige Fragen

Wie installiere ich Entschlüsseln?

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

Welche Varianten hat Entschlüsseln?

3 Varianten, Überschrift, Links bei Hover und Code beim Scrollen.

Kann ein KI-Assistent Entschlüsseln einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Entschlüsseln mit aiw_components und installieren sie mit aiw_add @ai-website/scramble.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert