Kinetic Heading
Motion showpiecesFreeCharacter-split headline reveal — the signature hero entrance.
Install
npx shadcn@latest add https://labs.duku.design/r/kinetic-heading.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Kinetic Heading component (get_component "kinetic-heading", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Replay | Chars rise from 110% with 0.028s stagger, power4.out |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | Headline text (split by hand, no Club plugins). |
| trigger | "mount" | "scroll" | "mount" | When to play. |
| stagger / delay | number | 0.028 / 0 | Timing. |
Source — registry/default/motion/kinetic-heading.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
gsap.registerPlugin(ScrollTrigger);
export interface KineticHeadingProps
extends React.HTMLAttributes<HTMLHeadingElement> {
text: string;
as?: "h1" | "h2" | "h3";
trigger?: "mount" | "scroll";
/** Seconds per char. Default 0.028. */
stagger?: number;
delay?: number;
ref?: React.Ref<HTMLHeadingElement>;
}
const SIZE: Record<string, string> = {
h1: "text-4xl md:text-6xl",
h2: "text-3xl md:text-5xl",
h3: "text-2xl md:text-4xl",
};
export function KineticHeading({
text,
as = "h1",
trigger = "mount",
stagger = 0.028,
delay = 0,
className,
ref,
...rest
}: KineticHeadingProps) {
const rootRef = React.useRef<HTMLHeadingElement>(null);
const reduced = useReducedMotion();
const Tag = as;
React.useImperativeHandle(ref, () => rootRef.current as HTMLHeadingElement);
const words = React.useMemo(() => text.split(" "), [text]);
useGSAP(
() => {
const root = rootRef.current;
if (!root) return;
const chars = gsap.utils.toArray<HTMLElement>(
root.querySelectorAll('[data-slot="char"]')
);
if (reduced) {
gsap.set(root, { visibility: "visible" });
// clearProps drops any inline filter left by a pre-hydration pass
gsap.set(chars, { yPercent: 0, opacity: 1, clearProps: "filter" });
return;
}
gsap.set(chars, { yPercent: 110, opacity: 0, filter: "blur(8px)" });
gsap.set(root, { visibility: "visible" });
const vars: gsap.TweenVars = {
yPercent: 0,
opacity: 1,
filter: "blur(0px)",
duration: 0.9,
ease: "power4.out",
stagger,
delay,
clearProps: "filter",
};
if (trigger === "scroll") {
vars.scrollTrigger = { trigger: root, start: "top 85%", once: true };
}
gsap.to(chars, vars);
},
{ dependencies: [text, trigger, stagger, delay, reduced], scope: rootRef }
);
return (
<Tag
ref={rootRef}
data-slot="kinetic-heading"
aria-label={text}
style={{ visibility: "hidden" }}
className={cn(
"font-semibold tracking-tight text-foreground",
SIZE[as],
className
)}
{...rest}
>
{words.map((word, wi) => (
<React.Fragment key={wi}>
<span
data-slot="word"
aria-hidden="true"
className="inline-block overflow-hidden align-bottom"
>
{Array.from(word).map((char, ci) => (
<span key={ci} data-slot="char" className="inline-block">
{char}
</span>
))}
</span>
{wi < words.length - 1 ? " " : null}
</React.Fragment>
))}
</Tag>
);
}