Skip to content
Motion
FreeIn reviewv1.1.0

Kinetic Headline

A headline that rises into place word by word behind a mask, on CSS alone, with serif and spectrum accents.

npx shadcn@latest add @vibebox/kinetic-headline

Controls

Plays
0.06
0
<KineticHeadline />

About

An entrance for the most important words on a page. Each word sits in its own mask and rises into place on a short stagger, with a slight tilt that settles as it lands. A phrase can switch to the serif voice or to the spectrum gradient for emphasis. In mount mode the whole effect runs on CSS, so a hero heading animates before the page hydrates and costs no JavaScript; in view mode a tiny trigger plays it once when the heading scrolls in, and never hides a heading that is already on screen. The words are real text from the first paint. Ported from the VibeBox site's headings.

Use it when

  • A hero or section heading should arrive with intent.
  • A page needs a typographic moment without an image or 3D piece.

Skip it when

  • The heading is long body copy; three to fourteen words read best.
  • Several headings on one screen would all animate at once.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/kinetic-headline
  3. 3Use it:
    import { KineticHeadline } from "@/components/vibebox/kinetic-headline";
    
    <KineticHeadline
      as="h1"
      segments={["Websites that feel", { text: "made", variant: "serif" }, "and", { text: "launch fast.", variant: "spectrum" }]}
      className="text-5xl font-semibold tracking-tight"
    />

Code

2 files
components/vibebox/kinetic-headline/index.tsx
/**
 * VibeBox Motion · Kinetic Headline
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { Fragment, type CSSProperties, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { KineticTrigger } from "./kinetic-headline.client";

/*
 * A headline that rises into place word by word behind a mask. "mount" runs on CSS alone, so an
 * above-the-fold heading animates before hydration; "view" plays once, when the heading scrolls
 * in. The words are real text the whole time, for search engines and screen readers.
 */

export type KineticSegment =
  | string
  | { text: string; variant?: "serif" | "spectrum"; nowrap?: boolean }
  | { br: true; desktopOnly?: boolean };

type Word = { text: string; variant?: "serif" | "spectrum" } | { br: true; desktopOnly?: boolean };

const KEYFRAMES = `
@keyframes vb-kh-rise { from { transform: translate3d(0, 108%, 0) rotate(2deg); } to { transform: none; } }
@media (prefers-reduced-motion: no-preference) {
  [data-vb="kinetic-headline"][data-mode="mount"] [data-vb-kh-word],
  [data-vb="kinetic-headline"][data-state="in"] [data-vb-kh-word] {
    animation: vb-kh-rise calc(var(--vb-duration-section, 520ms) * 1.8) var(--ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1)) both;
    animation-delay: var(--vb-kh-delay, 0ms);
  }
  [data-vb="kinetic-headline"][data-state="armed"] [data-vb-kh-word] { transform: translate3d(0, 108%, 0) rotate(2deg); }
}
`;

export type KineticHeadlineProps = {
  /** The heading text: plain strings, styled phrases, and line breaks. */
  segments: readonly KineticSegment[];
  /** The element to render. */
  as?: "h1" | "h2" | "h3" | "h4" | "p";
  /** "mount" plays on load with CSS alone; "view" plays once when the heading scrolls into view. */
  mode?: "mount" | "view";
  /** Seconds before the first word rises. */
  delay?: number;
  /** Seconds between words. */
  stagger?: number;
  /** Element id, for a section's aria-labelledby. */
  id?: string;
  className?: string;
};

function toWords(segments: readonly KineticSegment[]): Word[] {
  const words: Word[] = [];
  const split = (text: string, variant?: "serif" | "spectrum") =>
    text
      .split(/\s+/)
      .filter(Boolean)
      .forEach((part) => words.push({ text: part, variant }));
  for (const segment of segments) {
    if (typeof segment === "string") split(segment);
    else if ("br" in segment) words.push(segment);
    // One unit keeps a spectrum gradient continuous, or keeps a phrase on one line.
    else if (segment.variant === "spectrum" || segment.nowrap) words.push({ text: segment.text, variant: segment.variant });
    else split(segment.text, segment.variant);
  }
  return words;
}

export function KineticHeadline({ segments, as: Tag = "h2", mode = "mount", delay = 0, stagger = 0.06, id, className }: KineticHeadlineProps) {
  const words = toWords(segments);
  let order = 0;
  const content: ReactNode[] = words.map((word, index) => {
    if ("br" in word) return <br key={`br-${index}`} className={word.desktopOnly ? "hidden md:inline" : undefined} />;
    const wait = Math.round((delay + order++ * stagger) * 1000);
    const next = words[index + 1];
    // A desktop-only break is hidden on small screens, so the words still need a space.
    const space = next && (!("br" in next) || next.desktopOnly) ? " " : "";
    return (
      <Fragment key={`${word.text}-${index}`}>
        <span className="-mt-[0.04em] -mr-[0.06em] -mb-[0.14em] inline-block overflow-hidden pt-[0.04em] pr-[0.06em] pb-[0.14em] align-top whitespace-nowrap">
          <span
            data-vb-kh-word=""
            className={cn(
              "inline-block",
              word.variant === "serif" && "pr-[0.08em] font-serif font-normal tracking-[-0.02em] italic",
              word.variant === "spectrum" && "text-spectrum",
            )}
            style={{ "--vb-kh-delay": `${wait}ms` } as CSSProperties}
          >
            {word.text}
          </span>
        </span>
        {space}
      </Fragment>
    );
  });
  return (
    <Tag id={id} data-vb="kinetic-headline" data-mode={mode} data-state={mode === "view" ? "idle" : undefined} className={cn("block text-balance", className)}>
      <style href="vb-kinetic-headline" precedence="vb">
        {KEYFRAMES}
      </style>
      {content}
      {mode === "view" && <KineticTrigger />}
    </Tag>
  );
}
components/vibebox/kinetic-headline/kinetic-headline.client.tsx
/**
 * VibeBox Motion · Kinetic Headline
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useEffect, useRef } from "react";

/**
 * "view" mode: hides the words only if the heading starts below the fold (so nothing on screen
 * ever flickers), then lets them rise once when it scrolls in. Without JavaScript, or with
 * reduced motion, the words simply stay put.
 */
export function KineticTrigger() {
  const ref = useRef<HTMLSpanElement>(null);
  useEffect(() => {
    const root = ref.current?.closest<HTMLElement>('[data-vb="kinetic-headline"]');
    if (!root || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const box = root.getBoundingClientRect();
    if (box.top < window.innerHeight && box.bottom > 0) return;
    root.dataset.state = "armed";
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry?.isIntersecting) return;
        root.dataset.state = "in";
        observer.disconnect();
      },
      { threshold: 0.35 },
    );
    observer.observe(root);
    return () => observer.disconnect();
  }, []);
  return <span ref={ref} hidden />;
}

Props

PropTypeDefaultDescription
segments *readonly KineticSegment[]—Strings, styled phrases ({ text, variant: "serif" | "spectrum", nowrap }) and breaks ({ br, desktopOnly }).
as"h1" | "h2" | "h3" | "h4" | "p""h2"The element to render.
mode"mount" | "view""mount"Play on load (CSS only), or once when scrolled into view.
delaynumber0Seconds before the first word rises.
staggernumber0.06Seconds between words.
idstring—Element id, for a section's aria-labelledby.
classNamestring—Merged with cn(); set the type size and weight here.

Accessibility and motion

Keyboard
Not focusable; it is a heading.
Screen readers
Reads as one heading with normal spacing; the masks are layout only.
Touch
Nothing to touch.
Reduced motion
The heading is simply there: no rise, no mask movement.

Edge cases and limits

  • Very long headings stagger for a long time; lower stagger or split the heading.
  • A spectrum phrase stays on one line so its gradient is continuous.
  • A headline whose last word melts into the next option

    One word in a headline cycles through options with a liquid morph: "Websites that sell, book, grow".

    Text · CSS

  • Three statistics whose digits spin into place

    Rolling digits for real numbers: each digit spins once into place when visible, and screen readers get the value.

    Text · CSS

  • A ring of light bars pulsing around a floating glass box

    A crown of light bars pulsing like a visualizer around a floating glass box; bars rise toward the pointer.

    3D · three.js

↑ ↓ to move↵ to openesc to close