COMPONENT / decrypt-text
Decrypt Text_
Random glyphs that resolve left to right into the real string once on mount; screen readers get the real string. Requires @cybr/tokens.
Install
1. Map the
@cybrnamespace, once per project. Add this inside yourcomponents.json, or into its existing"registries"object. Nocomponents.jsonyet? Runnpx shadcn@latest initfirst to create one."registries": { "@cybr": "https://cybr-ui.com/r/{name}.json" }2. Add components:
$ npx shadcn@latest add @cybr/decrypt-text
If shadcn reports Unknown registry "@cybr", step 1 is missing from that project's components.json.
Preview
ACCESS GRANTED
registry/ui/decrypt-text.tsx
"use client";
import { useEffect, useState, type ComponentProps } from "react";
import { useReducedMotion } from "@/lib/use-reduced-motion";
import { cn } from "@/lib/utils";
const GLYPHS = "#%&@$*!<>/\\|=+01ΞΣ";
const TICK_MS = 50;
const TICKS_PER_CHAR = 2.5;
function totalTicks(text: string) {
return Math.ceil(text.length * TICKS_PER_CHAR);
}
// Deterministic, so a given text and tick always draw the same frame.
function frame(text: string, tick: number) {
const revealed = Math.floor(tick / TICKS_PER_CHAR);
let out = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i];
out += ch === " " || i < revealed ? ch : GLYPHS[(i * 7 + tick * 3) % GLYPHS.length];
}
return out;
}
// Random glyphs that resolve left to right once on mount. Screen readers get
// the real string from a visually hidden copy; the scramble is aria-hidden.
// Static-first: the server, no-JS and reduced-motion render is the final text.
export function DecryptText({
text,
className,
...props
}: Omit<ComponentProps<"span">, "children"> & { text: string }) {
const reduced = useReducedMotion();
const [tick, setTick] = useState<number | null>(null);
useEffect(() => {
if (reduced) return;
const end = totalTicks(text);
let current = 0;
const timer = setInterval(() => {
current += 1;
setTick(current);
if (current >= end) clearInterval(timer);
}, TICK_MS);
return () => {
clearInterval(timer);
setTick(null);
};
}, [text, reduced]);
const shown = reduced || tick === null ? text : frame(text, tick);
return (
<span className={cn("inline-block", className)} {...props}>
<span className="sr-only">{text}</span>
<span aria-hidden="true" className="whitespace-pre">
{shown}
</span>
</span>
);
}
Repo source. The shadcn CLI rewrites import aliases to match your project.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The real string. Shown scrambled, then resolved, and always exposed to screen readers as-is. |
| className | string | — | Extra classes, merged after the defaults with cn(). |
| ...props | Omit<ComponentProps<"span">, "children"> | — | Every other native <span> attribute and ref is forwarded. |
Usage
Needs the stylesheet from @cybr/tokens, and @cybr/use-reduced-motion arrives with it. After the install, import the tokens in app/globals.css:
@import "tailwindcss";
@import "../styles/cybr-tokens.css";
import { DecryptText } from "@/components/ui/decrypt-text";
export function Banner() {
return (
<h1 className="font-display text-6xl uppercase">
<DecryptText text="ACCESS GRANTED" />
</h1>
);
}
Behaviour
- Trigger: runs once when it mounts. Change
textto run it again. - Timing: every 50ms each unresolved character shows a glyph from
#%&@$*!<>/\|=+01ΞΣ. One more character resolves every 125ms, left to right, so 14 characters take about 1.75s. Spaces never scramble. - Finished: the real string, static.
- Static: Before hydration, without JavaScript and under
prefers-reduced-motion, it renders the real string with no scramble. On the client it can show the real string for one tick before the scramble starts. - Typography: inherits font, size and colour from its parent, so it works inside any heading or label.
Accessibility
- The real string sits in a visually hidden copy; the scrambling copy is
aria-hidden. Screen readers readtextonce and never hear glyphs. - The scramble runs once, for under two seconds, and swaps glyph shapes rather than colours, so it does not produce repeated flashes (WCAG 2.3.1).
- Under
prefers-reduced-motionno scramble runs. - Glyph widths vary in proportional fonts, so the line can shift slightly while it resolves. Use a monospace font if that matters to your layout.
- It inherits colour, so keep the text at 4.5:1 or above against its background.