Skip to content
Dethink Components

ComponentsShaderHeroText

ShaderHeroText

Expressive headlines rendered with WebGL. Real HTML keeps the message accessible, while shaders add fluid motion, reflective materials, and particles you can move with your mouse.

On this page

Try particle follow first. Every preview supports static text and theme switching; one-shot effects also have replay controls.

Particle follow

Letters made from individual particles. Nearby dots trail the mouse, then return home as it moves away.

06 / Particle follow

A thousand points. One idea.

Move your mouse over the words. Nearby particles form a soft tail behind your pointer; the dots you leave behind return to their letters.

Typography in motionDethink / Experiments
Show sourceexamples/shader-hero-text/effect-preview.tsx
examples/shader-hero-text/effect-preview.tsx
"use client";

import { useState } from "react";
import {
  ShaderHeroText,
  type ShaderHeroTextAnimation,
} from "@dethink/components";

const copy: Record<
  ShaderHeroTextAnimation,
  { label: string; text: string; description: string }
> = {
  "liquid-ripple": {
    label: "01 / Liquid ripple",
    text: "Make waves.\nLeave an impression.",
    description:
      "A ripple travels through the letters and settles into clarity.",
  },
  "chromatic-refraction": {
    label: "02 / Chromatic refraction",
    text: "A different\npoint of view.",
    description:
      "Prismatic edges separate, bend, and find their way back together.",
  },
  "noise-dissolve": {
    label: "03 / Noise dissolve",
    text: "Ideas take shape.",
    description:
      "An organic field of fragments resolves into a complete thought.",
  },
  "wave-distortion": {
    label: "04 / Wave distortion",
    text: "Nothing stands still.",
    description:
      "One broad wave passes through the headline before it comes to rest.",
  },
  "liquid-metal": {
    label: "05 / Liquid metal",
    text: "Forged in motion.",
    description:
      "Reflective bands move through the letterforms like polished metal.",
  },
  "particle-follow": {
    label: "06 / Particle follow",
    text: "A thousand points.\nOne idea.",
    description:
      "Move your mouse over the words. Nearby particles form a soft tail behind your pointer; the dots you leave behind return to their letters.",
  },
};

export function ShaderHeroTextPreview({
  animation = "particle-follow",
}: {
  animation?: ShaderHeroTextAnimation;
}) {
  const [replay, setReplay] = useState(0);
  const [staticText, setStaticText] = useState(false);
  const [dark, setDark] = useState(false);
  const [editing, setEditing] = useState(false);
  const [customText, setCustomText] = useState<string | null>(null);
  const item = copy[animation];
  return (
    <section
      data-preview={animation}
      data-theme={dark ? "dark" : "light"}
      className="border-border bg-background text-foreground relative w-full overflow-hidden rounded-2xl border"
    >
      <div className="border-border flex flex-wrap items-center justify-between gap-3 border-b px-6 py-4">
        <span className="text-muted-foreground font-mono text-xs tracking-widest uppercase">
          {item.label}
        </span>
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => setEditing(!editing)}
            aria-expanded={editing}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Edit text
          </button>
          <button
            type="button"
            onClick={() => setDark(!dark)}
            aria-pressed={dark}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Dark
          </button>
          <button
            type="button"
            onClick={() => setStaticText(!staticText)}
            aria-pressed={staticText}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Static
          </button>
          <button
            type="button"
            onClick={() => setReplay(replay + 1)}
            className="bg-primary text-primary-foreground focus-visible:outline-ring rounded-md px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            {animation === "particle-follow" ? "Reset" : "Replay"}
          </button>
        </div>
      </div>
      {editing && (
        <label className="border-border text-muted-foreground block border-b px-6 py-4 text-xs">
          Headline text
          <textarea
            aria-label="Headline text"
            value={customText ?? item.text}
            maxLength={300}
            onChange={(event) => setCustomText(event.target.value)}
            className="border-border bg-background text-foreground focus-visible:outline-ring mt-2 block w-full rounded-md border p-3 text-sm focus-visible:outline-2"
          />
        </label>
      )}
      <div className="px-6 py-16 sm:px-12 sm:py-24">
        {/* The text prop supplies the semantic heading content. */}
        {/* eslint-disable-next-line jsx-a11y/heading-has-content */}
        <ShaderHeroText
          as="h2"
          animation={animation}
          text={customText ?? item.text}
          replayKey={replay}
          reducedMotion={staticText ? "always" : "user"}
          intensity={0.85}
          className="text-[clamp(2.25rem,5vw,5.5rem)] leading-[1.1] font-semibold tracking-[-0.045em] text-balance"
        />
        <p className="text-muted-foreground mt-8 max-w-lg text-sm leading-relaxed">
          {item.description}
        </p>
      </div>
      <div className="border-border text-muted-foreground flex justify-between border-t px-6 py-3 font-mono text-[10px] tracking-widest uppercase">
        <span>Typography in motion</span>
        <span>Dethink / Experiments</span>
      </div>
    </section>
  );
}

Liquid ripple

A radial ripple bends the letterforms and settles.

01 / Liquid ripple

Make waves. Leave an impression.

A ripple travels through the letters and settles into clarity.

Typography in motionDethink / Experiments
Show sourceexamples/shader-hero-text/effect-preview.tsx
examples/shader-hero-text/effect-preview.tsx
"use client";

import { useState } from "react";
import {
  ShaderHeroText,
  type ShaderHeroTextAnimation,
} from "@dethink/components";

const copy: Record<
  ShaderHeroTextAnimation,
  { label: string; text: string; description: string }
> = {
  "liquid-ripple": {
    label: "01 / Liquid ripple",
    text: "Make waves.\nLeave an impression.",
    description:
      "A ripple travels through the letters and settles into clarity.",
  },
  "chromatic-refraction": {
    label: "02 / Chromatic refraction",
    text: "A different\npoint of view.",
    description:
      "Prismatic edges separate, bend, and find their way back together.",
  },
  "noise-dissolve": {
    label: "03 / Noise dissolve",
    text: "Ideas take shape.",
    description:
      "An organic field of fragments resolves into a complete thought.",
  },
  "wave-distortion": {
    label: "04 / Wave distortion",
    text: "Nothing stands still.",
    description:
      "One broad wave passes through the headline before it comes to rest.",
  },
  "liquid-metal": {
    label: "05 / Liquid metal",
    text: "Forged in motion.",
    description:
      "Reflective bands move through the letterforms like polished metal.",
  },
  "particle-follow": {
    label: "06 / Particle follow",
    text: "A thousand points.\nOne idea.",
    description:
      "Move your mouse over the words. Nearby particles form a soft tail behind your pointer; the dots you leave behind return to their letters.",
  },
};

export function ShaderHeroTextPreview({
  animation = "particle-follow",
}: {
  animation?: ShaderHeroTextAnimation;
}) {
  const [replay, setReplay] = useState(0);
  const [staticText, setStaticText] = useState(false);
  const [dark, setDark] = useState(false);
  const [editing, setEditing] = useState(false);
  const [customText, setCustomText] = useState<string | null>(null);
  const item = copy[animation];
  return (
    <section
      data-preview={animation}
      data-theme={dark ? "dark" : "light"}
      className="border-border bg-background text-foreground relative w-full overflow-hidden rounded-2xl border"
    >
      <div className="border-border flex flex-wrap items-center justify-between gap-3 border-b px-6 py-4">
        <span className="text-muted-foreground font-mono text-xs tracking-widest uppercase">
          {item.label}
        </span>
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => setEditing(!editing)}
            aria-expanded={editing}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Edit text
          </button>
          <button
            type="button"
            onClick={() => setDark(!dark)}
            aria-pressed={dark}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Dark
          </button>
          <button
            type="button"
            onClick={() => setStaticText(!staticText)}
            aria-pressed={staticText}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Static
          </button>
          <button
            type="button"
            onClick={() => setReplay(replay + 1)}
            className="bg-primary text-primary-foreground focus-visible:outline-ring rounded-md px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            {animation === "particle-follow" ? "Reset" : "Replay"}
          </button>
        </div>
      </div>
      {editing && (
        <label className="border-border text-muted-foreground block border-b px-6 py-4 text-xs">
          Headline text
          <textarea
            aria-label="Headline text"
            value={customText ?? item.text}
            maxLength={300}
            onChange={(event) => setCustomText(event.target.value)}
            className="border-border bg-background text-foreground focus-visible:outline-ring mt-2 block w-full rounded-md border p-3 text-sm focus-visible:outline-2"
          />
        </label>
      )}
      <div className="px-6 py-16 sm:px-12 sm:py-24">
        {/* The text prop supplies the semantic heading content. */}
        {/* eslint-disable-next-line jsx-a11y/heading-has-content */}
        <ShaderHeroText
          as="h2"
          animation={animation}
          text={customText ?? item.text}
          replayKey={replay}
          reducedMotion={staticText ? "always" : "user"}
          intensity={0.85}
          className="text-[clamp(2.25rem,5vw,5.5rem)] leading-[1.1] font-semibold tracking-[-0.045em] text-balance"
        />
        <p className="text-muted-foreground mt-8 max-w-lg text-sm leading-relaxed">
          {item.description}
        </p>
      </div>
      <div className="border-border text-muted-foreground flex justify-between border-t px-6 py-3 font-mono text-[10px] tracking-widest uppercase">
        <span>Typography in motion</span>
        <span>Dethink / Experiments</span>
      </div>
    </section>
  );
}

Chromatic refraction

Prismatic fringes separate and reunite around the letters.

02 / Chromatic refraction

A different point of view.

Prismatic edges separate, bend, and find their way back together.

Typography in motionDethink / Experiments
Show sourceexamples/shader-hero-text/effect-preview.tsx
examples/shader-hero-text/effect-preview.tsx
"use client";

import { useState } from "react";
import {
  ShaderHeroText,
  type ShaderHeroTextAnimation,
} from "@dethink/components";

const copy: Record<
  ShaderHeroTextAnimation,
  { label: string; text: string; description: string }
> = {
  "liquid-ripple": {
    label: "01 / Liquid ripple",
    text: "Make waves.\nLeave an impression.",
    description:
      "A ripple travels through the letters and settles into clarity.",
  },
  "chromatic-refraction": {
    label: "02 / Chromatic refraction",
    text: "A different\npoint of view.",
    description:
      "Prismatic edges separate, bend, and find their way back together.",
  },
  "noise-dissolve": {
    label: "03 / Noise dissolve",
    text: "Ideas take shape.",
    description:
      "An organic field of fragments resolves into a complete thought.",
  },
  "wave-distortion": {
    label: "04 / Wave distortion",
    text: "Nothing stands still.",
    description:
      "One broad wave passes through the headline before it comes to rest.",
  },
  "liquid-metal": {
    label: "05 / Liquid metal",
    text: "Forged in motion.",
    description:
      "Reflective bands move through the letterforms like polished metal.",
  },
  "particle-follow": {
    label: "06 / Particle follow",
    text: "A thousand points.\nOne idea.",
    description:
      "Move your mouse over the words. Nearby particles form a soft tail behind your pointer; the dots you leave behind return to their letters.",
  },
};

export function ShaderHeroTextPreview({
  animation = "particle-follow",
}: {
  animation?: ShaderHeroTextAnimation;
}) {
  const [replay, setReplay] = useState(0);
  const [staticText, setStaticText] = useState(false);
  const [dark, setDark] = useState(false);
  const [editing, setEditing] = useState(false);
  const [customText, setCustomText] = useState<string | null>(null);
  const item = copy[animation];
  return (
    <section
      data-preview={animation}
      data-theme={dark ? "dark" : "light"}
      className="border-border bg-background text-foreground relative w-full overflow-hidden rounded-2xl border"
    >
      <div className="border-border flex flex-wrap items-center justify-between gap-3 border-b px-6 py-4">
        <span className="text-muted-foreground font-mono text-xs tracking-widest uppercase">
          {item.label}
        </span>
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => setEditing(!editing)}
            aria-expanded={editing}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Edit text
          </button>
          <button
            type="button"
            onClick={() => setDark(!dark)}
            aria-pressed={dark}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Dark
          </button>
          <button
            type="button"
            onClick={() => setStaticText(!staticText)}
            aria-pressed={staticText}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Static
          </button>
          <button
            type="button"
            onClick={() => setReplay(replay + 1)}
            className="bg-primary text-primary-foreground focus-visible:outline-ring rounded-md px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            {animation === "particle-follow" ? "Reset" : "Replay"}
          </button>
        </div>
      </div>
      {editing && (
        <label className="border-border text-muted-foreground block border-b px-6 py-4 text-xs">
          Headline text
          <textarea
            aria-label="Headline text"
            value={customText ?? item.text}
            maxLength={300}
            onChange={(event) => setCustomText(event.target.value)}
            className="border-border bg-background text-foreground focus-visible:outline-ring mt-2 block w-full rounded-md border p-3 text-sm focus-visible:outline-2"
          />
        </label>
      )}
      <div className="px-6 py-16 sm:px-12 sm:py-24">
        {/* The text prop supplies the semantic heading content. */}
        {/* eslint-disable-next-line jsx-a11y/heading-has-content */}
        <ShaderHeroText
          as="h2"
          animation={animation}
          text={customText ?? item.text}
          replayKey={replay}
          reducedMotion={staticText ? "always" : "user"}
          intensity={0.85}
          className="text-[clamp(2.25rem,5vw,5.5rem)] leading-[1.1] font-semibold tracking-[-0.045em] text-balance"
        />
        <p className="text-muted-foreground mt-8 max-w-lg text-sm leading-relaxed">
          {item.description}
        </p>
      </div>
      <div className="border-border text-muted-foreground flex justify-between border-t px-6 py-3 font-mono text-[10px] tracking-widest uppercase">
        <span>Typography in motion</span>
        <span>Dethink / Experiments</span>
      </div>
    </section>
  );
}

Noise dissolve

A seeded organic mask reveals the complete headline.

03 / Noise dissolve

Ideas take shape.

An organic field of fragments resolves into a complete thought.

Typography in motionDethink / Experiments
Show sourceexamples/shader-hero-text/effect-preview.tsx
examples/shader-hero-text/effect-preview.tsx
"use client";

import { useState } from "react";
import {
  ShaderHeroText,
  type ShaderHeroTextAnimation,
} from "@dethink/components";

const copy: Record<
  ShaderHeroTextAnimation,
  { label: string; text: string; description: string }
> = {
  "liquid-ripple": {
    label: "01 / Liquid ripple",
    text: "Make waves.\nLeave an impression.",
    description:
      "A ripple travels through the letters and settles into clarity.",
  },
  "chromatic-refraction": {
    label: "02 / Chromatic refraction",
    text: "A different\npoint of view.",
    description:
      "Prismatic edges separate, bend, and find their way back together.",
  },
  "noise-dissolve": {
    label: "03 / Noise dissolve",
    text: "Ideas take shape.",
    description:
      "An organic field of fragments resolves into a complete thought.",
  },
  "wave-distortion": {
    label: "04 / Wave distortion",
    text: "Nothing stands still.",
    description:
      "One broad wave passes through the headline before it comes to rest.",
  },
  "liquid-metal": {
    label: "05 / Liquid metal",
    text: "Forged in motion.",
    description:
      "Reflective bands move through the letterforms like polished metal.",
  },
  "particle-follow": {
    label: "06 / Particle follow",
    text: "A thousand points.\nOne idea.",
    description:
      "Move your mouse over the words. Nearby particles form a soft tail behind your pointer; the dots you leave behind return to their letters.",
  },
};

export function ShaderHeroTextPreview({
  animation = "particle-follow",
}: {
  animation?: ShaderHeroTextAnimation;
}) {
  const [replay, setReplay] = useState(0);
  const [staticText, setStaticText] = useState(false);
  const [dark, setDark] = useState(false);
  const [editing, setEditing] = useState(false);
  const [customText, setCustomText] = useState<string | null>(null);
  const item = copy[animation];
  return (
    <section
      data-preview={animation}
      data-theme={dark ? "dark" : "light"}
      className="border-border bg-background text-foreground relative w-full overflow-hidden rounded-2xl border"
    >
      <div className="border-border flex flex-wrap items-center justify-between gap-3 border-b px-6 py-4">
        <span className="text-muted-foreground font-mono text-xs tracking-widest uppercase">
          {item.label}
        </span>
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => setEditing(!editing)}
            aria-expanded={editing}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Edit text
          </button>
          <button
            type="button"
            onClick={() => setDark(!dark)}
            aria-pressed={dark}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Dark
          </button>
          <button
            type="button"
            onClick={() => setStaticText(!staticText)}
            aria-pressed={staticText}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Static
          </button>
          <button
            type="button"
            onClick={() => setReplay(replay + 1)}
            className="bg-primary text-primary-foreground focus-visible:outline-ring rounded-md px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            {animation === "particle-follow" ? "Reset" : "Replay"}
          </button>
        </div>
      </div>
      {editing && (
        <label className="border-border text-muted-foreground block border-b px-6 py-4 text-xs">
          Headline text
          <textarea
            aria-label="Headline text"
            value={customText ?? item.text}
            maxLength={300}
            onChange={(event) => setCustomText(event.target.value)}
            className="border-border bg-background text-foreground focus-visible:outline-ring mt-2 block w-full rounded-md border p-3 text-sm focus-visible:outline-2"
          />
        </label>
      )}
      <div className="px-6 py-16 sm:px-12 sm:py-24">
        {/* The text prop supplies the semantic heading content. */}
        {/* eslint-disable-next-line jsx-a11y/heading-has-content */}
        <ShaderHeroText
          as="h2"
          animation={animation}
          text={customText ?? item.text}
          replayKey={replay}
          reducedMotion={staticText ? "always" : "user"}
          intensity={0.85}
          className="text-[clamp(2.25rem,5vw,5.5rem)] leading-[1.1] font-semibold tracking-[-0.045em] text-balance"
        />
        <p className="text-muted-foreground mt-8 max-w-lg text-sm leading-relaxed">
          {item.description}
        </p>
      </div>
      <div className="border-border text-muted-foreground flex justify-between border-t px-6 py-3 font-mono text-[10px] tracking-widest uppercase">
        <span>Typography in motion</span>
        <span>Dethink / Experiments</span>
      </div>
    </section>
  );
}

Wave distortion

A coherent wave passes through the text.

04 / Wave distortion

Nothing stands still.

One broad wave passes through the headline before it comes to rest.

Typography in motionDethink / Experiments
Show sourceexamples/shader-hero-text/effect-preview.tsx
examples/shader-hero-text/effect-preview.tsx
"use client";

import { useState } from "react";
import {
  ShaderHeroText,
  type ShaderHeroTextAnimation,
} from "@dethink/components";

const copy: Record<
  ShaderHeroTextAnimation,
  { label: string; text: string; description: string }
> = {
  "liquid-ripple": {
    label: "01 / Liquid ripple",
    text: "Make waves.\nLeave an impression.",
    description:
      "A ripple travels through the letters and settles into clarity.",
  },
  "chromatic-refraction": {
    label: "02 / Chromatic refraction",
    text: "A different\npoint of view.",
    description:
      "Prismatic edges separate, bend, and find their way back together.",
  },
  "noise-dissolve": {
    label: "03 / Noise dissolve",
    text: "Ideas take shape.",
    description:
      "An organic field of fragments resolves into a complete thought.",
  },
  "wave-distortion": {
    label: "04 / Wave distortion",
    text: "Nothing stands still.",
    description:
      "One broad wave passes through the headline before it comes to rest.",
  },
  "liquid-metal": {
    label: "05 / Liquid metal",
    text: "Forged in motion.",
    description:
      "Reflective bands move through the letterforms like polished metal.",
  },
  "particle-follow": {
    label: "06 / Particle follow",
    text: "A thousand points.\nOne idea.",
    description:
      "Move your mouse over the words. Nearby particles form a soft tail behind your pointer; the dots you leave behind return to their letters.",
  },
};

export function ShaderHeroTextPreview({
  animation = "particle-follow",
}: {
  animation?: ShaderHeroTextAnimation;
}) {
  const [replay, setReplay] = useState(0);
  const [staticText, setStaticText] = useState(false);
  const [dark, setDark] = useState(false);
  const [editing, setEditing] = useState(false);
  const [customText, setCustomText] = useState<string | null>(null);
  const item = copy[animation];
  return (
    <section
      data-preview={animation}
      data-theme={dark ? "dark" : "light"}
      className="border-border bg-background text-foreground relative w-full overflow-hidden rounded-2xl border"
    >
      <div className="border-border flex flex-wrap items-center justify-between gap-3 border-b px-6 py-4">
        <span className="text-muted-foreground font-mono text-xs tracking-widest uppercase">
          {item.label}
        </span>
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => setEditing(!editing)}
            aria-expanded={editing}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Edit text
          </button>
          <button
            type="button"
            onClick={() => setDark(!dark)}
            aria-pressed={dark}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Dark
          </button>
          <button
            type="button"
            onClick={() => setStaticText(!staticText)}
            aria-pressed={staticText}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Static
          </button>
          <button
            type="button"
            onClick={() => setReplay(replay + 1)}
            className="bg-primary text-primary-foreground focus-visible:outline-ring rounded-md px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            {animation === "particle-follow" ? "Reset" : "Replay"}
          </button>
        </div>
      </div>
      {editing && (
        <label className="border-border text-muted-foreground block border-b px-6 py-4 text-xs">
          Headline text
          <textarea
            aria-label="Headline text"
            value={customText ?? item.text}
            maxLength={300}
            onChange={(event) => setCustomText(event.target.value)}
            className="border-border bg-background text-foreground focus-visible:outline-ring mt-2 block w-full rounded-md border p-3 text-sm focus-visible:outline-2"
          />
        </label>
      )}
      <div className="px-6 py-16 sm:px-12 sm:py-24">
        {/* The text prop supplies the semantic heading content. */}
        {/* eslint-disable-next-line jsx-a11y/heading-has-content */}
        <ShaderHeroText
          as="h2"
          animation={animation}
          text={customText ?? item.text}
          replayKey={replay}
          reducedMotion={staticText ? "always" : "user"}
          intensity={0.85}
          className="text-[clamp(2.25rem,5vw,5.5rem)] leading-[1.1] font-semibold tracking-[-0.045em] text-balance"
        />
        <p className="text-muted-foreground mt-8 max-w-lg text-sm leading-relaxed">
          {item.description}
        </p>
      </div>
      <div className="border-border text-muted-foreground flex justify-between border-t px-6 py-3 font-mono text-[10px] tracking-widest uppercase">
        <span>Typography in motion</span>
        <span>Dethink / Experiments</span>
      </div>
    </section>
  );
}

Liquid metal

Reflective lighting bands travel across the letter interiors.

05 / Liquid metal

Forged in motion.

Reflective bands move through the letterforms like polished metal.

Typography in motionDethink / Experiments
Show sourceexamples/shader-hero-text/effect-preview.tsx
examples/shader-hero-text/effect-preview.tsx
"use client";

import { useState } from "react";
import {
  ShaderHeroText,
  type ShaderHeroTextAnimation,
} from "@dethink/components";

const copy: Record<
  ShaderHeroTextAnimation,
  { label: string; text: string; description: string }
> = {
  "liquid-ripple": {
    label: "01 / Liquid ripple",
    text: "Make waves.\nLeave an impression.",
    description:
      "A ripple travels through the letters and settles into clarity.",
  },
  "chromatic-refraction": {
    label: "02 / Chromatic refraction",
    text: "A different\npoint of view.",
    description:
      "Prismatic edges separate, bend, and find their way back together.",
  },
  "noise-dissolve": {
    label: "03 / Noise dissolve",
    text: "Ideas take shape.",
    description:
      "An organic field of fragments resolves into a complete thought.",
  },
  "wave-distortion": {
    label: "04 / Wave distortion",
    text: "Nothing stands still.",
    description:
      "One broad wave passes through the headline before it comes to rest.",
  },
  "liquid-metal": {
    label: "05 / Liquid metal",
    text: "Forged in motion.",
    description:
      "Reflective bands move through the letterforms like polished metal.",
  },
  "particle-follow": {
    label: "06 / Particle follow",
    text: "A thousand points.\nOne idea.",
    description:
      "Move your mouse over the words. Nearby particles form a soft tail behind your pointer; the dots you leave behind return to their letters.",
  },
};

export function ShaderHeroTextPreview({
  animation = "particle-follow",
}: {
  animation?: ShaderHeroTextAnimation;
}) {
  const [replay, setReplay] = useState(0);
  const [staticText, setStaticText] = useState(false);
  const [dark, setDark] = useState(false);
  const [editing, setEditing] = useState(false);
  const [customText, setCustomText] = useState<string | null>(null);
  const item = copy[animation];
  return (
    <section
      data-preview={animation}
      data-theme={dark ? "dark" : "light"}
      className="border-border bg-background text-foreground relative w-full overflow-hidden rounded-2xl border"
    >
      <div className="border-border flex flex-wrap items-center justify-between gap-3 border-b px-6 py-4">
        <span className="text-muted-foreground font-mono text-xs tracking-widest uppercase">
          {item.label}
        </span>
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={() => setEditing(!editing)}
            aria-expanded={editing}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Edit text
          </button>
          <button
            type="button"
            onClick={() => setDark(!dark)}
            aria-pressed={dark}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Dark
          </button>
          <button
            type="button"
            onClick={() => setStaticText(!staticText)}
            aria-pressed={staticText}
            className="border-border focus-visible:outline-ring rounded-md border px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            Static
          </button>
          <button
            type="button"
            onClick={() => setReplay(replay + 1)}
            className="bg-primary text-primary-foreground focus-visible:outline-ring rounded-md px-3 py-1.5 text-xs focus-visible:outline-2"
          >
            {animation === "particle-follow" ? "Reset" : "Replay"}
          </button>
        </div>
      </div>
      {editing && (
        <label className="border-border text-muted-foreground block border-b px-6 py-4 text-xs">
          Headline text
          <textarea
            aria-label="Headline text"
            value={customText ?? item.text}
            maxLength={300}
            onChange={(event) => setCustomText(event.target.value)}
            className="border-border bg-background text-foreground focus-visible:outline-ring mt-2 block w-full rounded-md border p-3 text-sm focus-visible:outline-2"
          />
        </label>
      )}
      <div className="px-6 py-16 sm:px-12 sm:py-24">
        {/* The text prop supplies the semantic heading content. */}
        {/* eslint-disable-next-line jsx-a11y/heading-has-content */}
        <ShaderHeroText
          as="h2"
          animation={animation}
          text={customText ?? item.text}
          replayKey={replay}
          reducedMotion={staticText ? "always" : "user"}
          intensity={0.85}
          className="text-[clamp(2.25rem,5vw,5.5rem)] leading-[1.1] font-semibold tracking-[-0.045em] text-balance"
        />
        <p className="text-muted-foreground mt-8 max-w-lg text-sm leading-relaxed">
          {item.description}
        </p>
      </div>
      <div className="border-border text-muted-foreground flex justify-between border-t px-6 py-3 font-mono text-[10px] tracking-widest uppercase">
        <span>Typography in motion</span>
        <span>Dethink / Experiments</span>
      </div>
    </section>
  );
}

Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.

View registry files

Import the component into your page or component file.

Usage
import { ShaderHeroText } from "@dethink/components";

<ShaderHeroText
  animation="particle-follow"
  text="Ideas come together."
  className="text-6xl font-semibold"
/>

The shader component accepts native heading attributes and a forwarded ref.

ShaderHeroText props
PropWhat it doesDefault
textstringComplete semantic heading. Supports explicit newlines and measured browser wrapping.Not set
animation"liquid-ripple" | "chromatic-refraction" | "noise-dissolve" | "wave-distortion" | "liquid-metal" | "particle-follow"Five one-shot shaders and one interactive particle effect."liquid-ripple"
as / ariaLabel"h1" | "h2" | "p" | "span" / stringSemantic root and optional accessible label override."h1" / undefined
trigger / active"mount" | "in-view" | "manual" / booleanActivation strategy. All effects suspend offscreen; active=false disables rendering. Toggle active for manual activation."in-view" / true
durationnumberSeconds, bounded to 0โ€“5 for one-shots. Particle return defaults to 1.2 seconds and is bounded to 0โ€“2; zero returns immediately.1.2โ€“1.8
delaynumberOne-shot delay in seconds, bounded to 0โ€“5. Particle following responds directly to input.0
intensity / seednumber / numberIntensity is bounded to 0โ€“1. Seed makes noise and particle offsets repeatable.0.5 / 0
replayKeystring | numberChange to replay a one-shot effect or reset particles to their formed letters.undefined
reducedMotion"user" | "always"Always respects the OS preference. Use always to request static HTML explicitly."user"
onAnimationStart / onAnimationComplete() => voidOne-shot start/completion, or particle following-start/return-completion. Cancellation, reset and fallback never emit completion.undefined

The complete heading is present before JavaScript runs. Canvas output is decorative and never receives focus. Reduced motion, forced colors, missing WebGL, unsupported typography, and touch devices using particle follow receive static HTML. Select text to reveal the HTML copy. Animations stop when hidden or offscreen.

Use your own loaded font, font size, line height, alignment, direction, and explicit line breaks. The component measures browser line wrapping and uses HTML when it cannot reproduce a text layout reliably. Set --shader-hero-text-base, --shader-hero-text-accent, and --shader-hero-text-sheen to theme the effect. Use short headings; vertical writing and transformed text are not supported.

For staggered words, typewriter, and other DOM/SVG effects, see HeroTextAnimation.