Browse the docs
TextNo. 66 of 109
Dissolve Text
Words or characters surfacing at scattered moments, the same on server and client.
Installation
pnpm dlx shadcn@latest add @corsair-ui/dissolve-textNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utilsuse-in-viewuse-entrance
- npm
clsxtailwind-merge
Examples
Words
The scatter is seeded by the text: the same on every render.
The fog lifts off the harbour and the coastline comes back, one headland at a time.
Characters
Out of the fog
API
| Prop | Type | Default |
|---|---|---|
splitWhat fades in on its own. | "words" | "characters" | "words" |
trigger / playAs in blur-text. | "load" | "in-view" / boolean | "load" |
delay / spread / durationIn ms: the wait, the window pieces pick their start from, and each fade. | number | 200 / 230 / 1000 |
Accessibility
- Screen readers get the text in one piece.
- The scatter is seeded by the text, so hydration never mismatches. With prefers-reduced-motion it holds still and shows the finished state.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Client component ("use client")
Source
1 file, installed as source you own
dissolve-text.tsxView on GitHub"use client";
import { Fragment, useMemo, type ComponentProps, type CSSProperties, type Ref } from "react";
import { useEntrance, type EntranceTrigger } from "@/registry/default/hooks/use-entrance";
import { cn } from "@/registry/default/lib/utils";
function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
/** Splits into user-perceived characters, so emoji and accents stay whole. */
function graphemes(text: string) {
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
return Array.from(segmenter.segment(text), ({ segment }) => segment);
}
return Array.from(text);
}
/**
* A small seeded generator (mulberry32). The same text always gets the same
* timings, so the server and the browser agree and hydration stays clean.
*/
function seededRandom(text: string) {
let seed = 2166136261;
for (let index = 0; index < text.length; index++) {
seed = Math.imul(seed ^ text.charCodeAt(index), 16777619);
}
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let value = Math.imul(seed ^ (seed >>> 15), 1 | seed);
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
type DissolveTextTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
interface DissolveTextProps extends Omit<ComponentProps<"p">, "children"> {
/** Plain text. */
children: string;
as?: DissolveTextTag;
/** Whether words or single characters fade in on their own. */
split?: "words" | "characters";
/** "load" plays on first paint with CSS alone; "in-view" when the text scrolls into view. */
trigger?: EntranceTrigger;
/** In-view: play the first time only. */
once?: boolean;
/** Takes over from `trigger`: `false` holds the text hidden, `true` plays it. */
play?: boolean;
/** Wait before anything appears, in ms. */
delay?: number;
/** The window, in ms, over which pieces pick their random start. */
spread?: number;
/** How long each piece takes to appear, in ms. */
duration?: number;
}
/**
* Text that dissolves in: words (or characters) surface at scattered
* moments inside a short window, like a chart coming out of the fog. The
* scatter is seeded by the text, so it is the same on every render and the
* server agrees with the browser. Screen readers get the text in one
* piece, and with `prefers-reduced-motion` it is shown as is.
*
* @example
* <DissolveText as="p" split="characters">Fog lifting over the harbour.</DissolveText>
*/
function DissolveText({
children,
as: Tag = "p",
split = "words",
trigger = "load",
once = true,
play,
delay = 200,
spread = 230,
duration = 1000,
className,
ref,
...props
}: DissolveTextProps) {
const [observe, phase] = useEntrance<HTMLElement>({ trigger, once, play });
const mergedRef = useMemo(() => mergeRefs(ref, observe), [ref, observe]);
const words = useMemo(() => {
const random = seededRandom(`${split}:${children}`);
return children
.trim()
.split(/\s+/)
.filter(Boolean)
.map((word) =>
(split === "characters" ? graphemes(word) : [word]).map((text) => ({
text,
// Two draws, so most pieces bunch in the middle of the window.
wait: (random() * 0.87 + random() * 0.13) * spread,
}))
);
}, [children, split, spread]);
const pieceStyle = (wait: number): CSSProperties =>
phase === "static"
? {}
: {
animationDelay: `${Math.round(delay + wait)}ms`,
animationDuration: `${duration}ms`,
animationPlayState: phase === "armed" ? "paused" : undefined,
};
return (
<Tag
ref={mergedRef as Ref<never>}
data-slot="dissolve-text"
data-state={phase}
className={className}
{...props}
>
<span className="sr-only">{children}</span>
<span aria-hidden="true">
{words.map((pieces, wordIndex) => (
<Fragment key={wordIndex}>
{wordIndex > 0 ? " " : null}
<span className="inline-block whitespace-nowrap">
{pieces.map(({ text, wait }, index) => (
<span
key={index}
data-slot="dissolve-text-piece"
className={cn(
"inline-block",
phase !== "static" && "motion-safe:animate-dissolve-text"
)}
style={pieceStyle(wait)}
>
{text}
</span>
))}
</span>
</Fragment>
))}
</span>
</Tag>
);
}
export { DissolveText, type DissolveTextProps };