Text Effects

Smoky Text

Letters condense out of drifting smoke, dissolving in blurred and haloed then settling crisp.

Install

npx shadcn@latest add @paragon/smoky-text

smoky-text.tsx

"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>
    </>
  );
}