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.
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.
Show sourceexamples/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.
Make waves. Leave an impression.
A ripple travels through the letters and settles into clarity.
Show sourceexamples/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.
A different point of view.
Prismatic edges separate, bend, and find their way back together.
Show sourceexamples/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.
Ideas take shape.
An organic field of fragments resolves into a complete thought.
Show sourceexamples/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.
Nothing stands still.
One broad wave passes through the headline before it comes to rest.
Show sourceexamples/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.
Forged in motion.
Reflective bands move through the letterforms like polished metal.
Show sourceexamples/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 filesImport the component into your page or component file.
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.
| Prop | What it does | Default |
|---|---|---|
textstring | Complete 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" / string | Semantic root and optional accessible label override. | "h1" / undefined |
trigger / active"mount" | "in-view" | "manual" / boolean | Activation strategy. All effects suspend offscreen; active=false disables rendering. Toggle active for manual activation. | "in-view" / true |
durationnumber | Seconds, 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 |
delaynumber | One-shot delay in seconds, bounded to 0โ5. Particle following responds directly to input. | 0 |
intensity / seednumber / number | Intensity is bounded to 0โ1. Seed makes noise and particle offsets repeatable. | 0.5 / 0 |
replayKeystring | number | Change 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() => void | One-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.