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
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ScrambleProps aus components/ui/scramble.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
textPflichtstringThe text it resolves to
asoptionalTagh1h2h3h4pspandiv
Element to render. Default span.
triggeroptionalScrambleTriggerloadviewin-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].
replayOnHoveroptionalbooleanScramble again on every hover and focus, also after view or load (not with scroll). Default true.
durationoptionalnumberMilliseconds until the last letter has resolved. Default 1100.
delayoptionalnumberMilliseconds before it starts. Default 0.
charactersoptionalstringCharacters to scramble with. Default: letters of the same case, digits for digits.
seedoptionalnumberSeed 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
'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.