文字解密
New随机字符从左到右逐个解析为真实文本——进入视口、悬停或 text 变化时播放。解码过程中宽度不抖动,「减少动效」偏好下直接显示最终文本。
Hover to decodemotion, anything
TSXcomponents/previews/motion/text-scramble.preview.tsx
"use client";
import { useEffect, useState } from "react";
import { TextScramble } from "@/components/motion/text-scramble";
const PHRASES = ["motion, anything", "ship, tasteful"];
export function TextScramblePreview() {
const [index, setIndex] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setIndex((i) => (i + 1) % PHRASES.length);
}, 3000);
return () => window.clearInterval(id);
}, []);
return (
<div className="flex flex-col gap-4">
<TextScramble
text="Hover to decode"
trigger="hover"
duration={500}
className="text-sm font-medium"
/>
<TextScramble text={PHRASES[index]} className="text-3xl font-semibold" />
</div>
);
}
TSXcomponents/motion/text-scramble.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/text-scramble
// Ported from motion-anything (nexu-io, Apache-2.0); upstream effect: reactbits.dev "Decrypted Text", redistributed with permission.
import { useInView, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface TextScrambleProps {
/** The real text. Changing it plays a scramble decode from the old value to the new one. */
text: string;
/** "view" decodes once on scroll into view (default). "hover" replays on pointer hover; touch devices fall back to "view". */
trigger?: "view" | "hover";
/** Total decode time in ms. */
duration?: number;
/** Glyphs shown while a character is still unresolved. */
charset?: string;
className?: string;
}
const DEFAULT_CHARSET = "!<>-_\\/[]{}=+*^?#";
export function TextScramble({
text,
trigger = "view",
duration = 700,
charset = DEFAULT_CHARSET,
className,
}: TextScrambleProps) {
const ref = useRef<HTMLSpanElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const inView = useInView(ref, { once: true, amount: 0.4 });
// Touch devices get phantom hover states that stick after tap — degrade to view.
const effectiveTrigger = trigger === "hover" && !canHover ? "view" : trigger;
const [display, setDisplay] = useState(text);
const prevTextRef = useRef(text);
const enteredRef = useRef(false);
const rafRef = useRef<number | null>(null);
const scrambleTo = useCallback(
(from: string, to: string) => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
if (reduce) {
setDisplay(to);
return;
}
const len = Math.max(from.length, to.length);
if (len === 0) {
setDisplay("");
return;
}
const totalFrames = Math.max(1, Math.round((duration / 1000) * 60));
let frame = 0;
const tick = () => {
const revealed = (frame / totalFrames) * len;
let out = "";
for (let i = 0; i < len; i++) {
const targetChar = to[i] ?? "";
if (i < revealed) {
out += targetChar;
} else if (targetChar === " ") {
out += " ";
} else {
out += charset[Math.floor(Math.random() * charset.length)];
}
}
setDisplay(out);
if (revealed >= len) {
setDisplay(to);
rafRef.current = null;
return;
}
frame += 1;
rafRef.current = requestAnimationFrame(tick);
};
tick();
},
[charset, duration, reduce],
);
// Entrance: decode once when the element scrolls into view.
useEffect(() => {
if (effectiveTrigger !== "view" || reduce) return;
if (!inView || enteredRef.current) return;
enteredRef.current = true;
scrambleTo(prevTextRef.current, text);
}, [effectiveTrigger, inView, reduce, scrambleTo, text]);
// Replay a scramble whenever the text prop itself changes, from the old value to the new one.
useEffect(() => {
if (prevTextRef.current === text) return;
const from = prevTextRef.current;
prevTextRef.current = text;
if (effectiveTrigger === "view" && !enteredRef.current) {
// Not on screen yet — swap silently, the entrance effect will decode it in later.
setDisplay(text);
return;
}
scrambleTo(from, text);
}, [text, effectiveTrigger, scrambleTo]);
useEffect(() => {
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, []);
const hoverHandlers =
effectiveTrigger === "hover" ? { onMouseEnter: () => scrambleTo(text, text) } : {};
return (
<span
ref={ref}
{...hoverHandlers}
className={cn("relative inline-block whitespace-nowrap align-baseline", className)}
>
<span className="sr-only">{text}</span>
{/* Invisible sizer: reserves box width from the final text so the scramble never jitters the layout. */}
<span aria-hidden="true" className="invisible">
{text}
</span>
<span aria-hidden="true" className="absolute inset-0 whitespace-nowrap">
{display}
</span>
</span>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/text-scramble
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/text-scramble.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/text-scramble
// Ported from motion-anything (nexu-io, Apache-2.0); upstream effect: reactbits.dev "Decrypted Text", redistributed with permission.
import { useInView, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface TextScrambleProps {
/** The real text. Changing it plays a scramble decode from the old value to the new one. */
text: string;
/** "view" decodes once on scroll into view (default). "hover" replays on pointer hover; touch devices fall back to "view". */
trigger?: "view" | "hover";
/** Total decode time in ms. */
duration?: number;
/** Glyphs shown while a character is still unresolved. */
charset?: string;
className?: string;
}
const DEFAULT_CHARSET = "!<>-_\\/[]{}=+*^?#";
export function TextScramble({
text,
trigger = "view",
duration = 700,
charset = DEFAULT_CHARSET,
className,
}: TextScrambleProps) {
const ref = useRef<HTMLSpanElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const inView = useInView(ref, { once: true, amount: 0.4 });
// Touch devices get phantom hover states that stick after tap — degrade to view.
const effectiveTrigger = trigger === "hover" && !canHover ? "view" : trigger;
const [display, setDisplay] = useState(text);
const prevTextRef = useRef(text);
const enteredRef = useRef(false);
const rafRef = useRef<number | null>(null);
const scrambleTo = useCallback(
(from: string, to: string) => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
if (reduce) {
setDisplay(to);
return;
}
const len = Math.max(from.length, to.length);
if (len === 0) {
setDisplay("");
return;
}
const totalFrames = Math.max(1, Math.round((duration / 1000) * 60));
let frame = 0;
const tick = () => {
const revealed = (frame / totalFrames) * len;
let out = "";
for (let i = 0; i < len; i++) {
const targetChar = to[i] ?? "";
if (i < revealed) {
out += targetChar;
} else if (targetChar === " ") {
out += " ";
} else {
out += charset[Math.floor(Math.random() * charset.length)];
}
}
setDisplay(out);
if (revealed >= len) {
setDisplay(to);
rafRef.current = null;
return;
}
frame += 1;
rafRef.current = requestAnimationFrame(tick);
};
tick();
},
[charset, duration, reduce],
);
// Entrance: decode once when the element scrolls into view.
useEffect(() => {
if (effectiveTrigger !== "view" || reduce) return;
if (!inView || enteredRef.current) return;
enteredRef.current = true;
scrambleTo(prevTextRef.current, text);
}, [effectiveTrigger, inView, reduce, scrambleTo, text]);
// Replay a scramble whenever the text prop itself changes, from the old value to the new one.
useEffect(() => {
if (prevTextRef.current === text) return;
const from = prevTextRef.current;
prevTextRef.current = text;
if (effectiveTrigger === "view" && !enteredRef.current) {
// Not on screen yet — swap silently, the entrance effect will decode it in later.
setDisplay(text);
return;
}
scrambleTo(from, text);
}, [text, effectiveTrigger, scrambleTo]);
useEffect(() => {
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, []);
const hoverHandlers =
effectiveTrigger === "hover" ? { onMouseEnter: () => scrambleTo(text, text) } : {};
return (
<span
ref={ref}
{...hoverHandlers}
className={cn("relative inline-block whitespace-nowrap align-baseline", className)}
>
<span className="sr-only">{text}</span>
{/* Invisible sizer: reserves box width from the final text so the scramble never jitters the layout. */}
<span aria-hidden="true" className="invisible">
{text}
</span>
<span aria-hidden="true" className="absolute inset-0 whitespace-nowrap">
{display}
</span>
</span>
);
}
API 参考
textstringThe real text. Changing it plays a scramble decode from the old value to the new one.
—trigger?"view" | "hover""view" decodes once on scroll into view (default). "hover" replays on pointer hover; touch devices fall back to "view".
viewduration?numberTotal decode time in ms.
700charset?stringGlyphs shown while a character is still unresolved.
!<>-_\/[]{}=+*^?#className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.