Letters condense out of drifting smoke, dissolving in blurred and haloed then settling crisp.
npx shadcn@latest add @paragon/smoky-text"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface SmokyTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The string to render, letter by letter. */
children: string;
/** Smoke tint (the blurred halo each letter condenses out of). */
color?: string;
/** Blur/spread intensity of the smoke, in px. */
intensity?: number;
/** Direction the smoke drifts in from. */
direction?: "up" | "down" | "left" | "right";
/** Milliseconds between each letter condensing. */
stagger?: number;
/** `view` — condense when scrolled into view. `hover` — on hover, replayable. */
trigger?: "view" | "hover";
/** Render the final text with no smoke. */
static?: boolean;
}
const OFFSETS: Record<
NonNullable<SmokyTextProps["direction"]>,
[number, number]
> = {
up: [0, 1],
down: [0, -1],
left: [1, 0],
right: [-1, 0],
};
/**
* SmokyText — letters condense out of smoke. Each glyph starts blurred, drifted,
* and transparent with a soft colored halo (a blurred `text-shadow`), then
* settles into a crisp letter as its shadow blur and offset collapse to zero.
* The dissolve staggers letter by letter and drifts in from a chosen direction,
* so the word reads like vapor pulling itself into type.
*
* All motion is `opacity`, `filter: blur`, `transform`, and the smoke's
* `text-shadow` — no layout properties. Real text stays in the DOM for screen
* readers. Reduced motion (or `static`) paints the final text immediately.
* `trigger="view"` fires once on scroll-in; `hover` replays.
*/
export function SmokyText({
children,
color = "#94a3b8",
intensity = 12,
direction = "up",
stagger = 55,
trigger = "view",
static: isStatic = false,
className,
style,
...props
}: SmokyTextProps) {
const id = React.useId().replace(/[:]/g, "");
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: trigger === "view", amount: 0.4 });
const reducedMotion = useReducedMotion() ?? false;
const [hovered, setHovered] = React.useState(false);
const chars = React.useMemo(() => Array.from(children), [children]);
const animated = !isStatic && !reducedMotion;
const active =
!animated || (trigger === "view" ? inView : hovered || false);
const [dx, dy] = OFFSETS[direction];
const drift = intensity * 1.4;
const keyframes = `@keyframes smoky-in-${id} {
0% {
opacity: 0;
filter: blur(${intensity}px);
transform: translate(${dx * drift}px, ${dy * drift}px) scale(1.08);
text-shadow:
0 0 ${intensity * 1.5}px var(--smoky-color),
0 0 ${intensity * 3}px var(--smoky-color);
}
55% {
opacity: 1;
text-shadow:
0 0 ${intensity * 0.6}px var(--smoky-color),
0 0 ${intensity * 1.2}px var(--smoky-color);
}
100% {
opacity: 1;
filter: blur(0px);
transform: translate(0, 0) scale(1);
text-shadow: 0 0 0 transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.smoky-${id} > span { animation: none !important; opacity: 1 !important;
filter: none !important; transform: none !important; text-shadow: none !important; }
}`;
if (isStatic || reducedMotion) {
return (
<span
data-slot="smoky-text"
className={cn("inline-block", className)}
style={style}
{...props}
>
{children}
</span>
);
}
return (
<>
<style href={`paragon-smoky-text-${id}`} precedence="paragon">
{keyframes}
</style>
<span
ref={ref}
data-slot="smoky-text"
className={cn(`smoky-${id} inline-block`, className)}
style={{ ["--smoky-color" as string]: color, ...style }}
onPointerEnter={trigger === "hover" ? () => setHovered(true) : undefined}
onPointerLeave={trigger === "hover" ? () => setHovered(false) : undefined}
{...props}
>
<span className="sr-only">{children}</span>
<span aria-hidden="true">
{chars.map((ch, i) =>
/\s/.test(ch) ? (
<React.Fragment key={i}>{ch}</React.Fragment>
) : (
<span
key={`${active ? "on" : "off"}-${i}`}
className="inline-block"
style={{
opacity: active ? undefined : 0,
animationName: active ? `smoky-in-${id}` : undefined,
animationDuration: "900ms",
animationTimingFunction: "var(--ease-out)",
animationFillMode: "both",
animationDelay: `${i * stagger}ms`,
willChange: "opacity, filter, transform",
}}
>
{ch}
</span>
),
)}
</span>
</span>
</>
);
}