Kinetic Heading

Motion showpiecesFree

Character-split headline reveal — the signature hero entrance.

Install

npx shadcn@latest add https://labs.duku.design/r/kinetic-heading.json

Ask 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

TryWhat happens
ReplayChars rise from 110% with 0.028s stagger, power4.out

Props

PropTypeDefaultDescription
textstringHeadline text (split by hand, no Club plugins).
trigger"mount" | "scroll""mount"When to play.
stagger / delaynumber0.028 / 0Timing.
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>
  );
}