Icon Headline
A headline with app-icon tiles inline that pop in and flip.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/icon-headline.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/icon-headline/icon-headline.tsx"use client"; import { useEffect, useState, type ElementType, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { ease } from "@/lib/motion"; export interface IconSlot { /** Icons that take turns in this slot, e.g. app icons. Decorative. */ icons: ReactNode[]; /** Background behind each icon, by index (falls back to the first). */ backgrounds?: string[]; } export interface IconHeadlineProps { /** Words and icon slots in order, e.g. ["Where", { icons: [...] }, "teams and agents"]. */ parts: (string | IconSlot)[]; /** Heading element. @default "h1" */ as?: ElementType; /** Milliseconds between icon swaps; 0 keeps the first icon. @default 2600 */ interval?: number; /** Seconds before the entrance starts. @default 0 */ delay?: number; /** Classes for the heading. */ className?: string; } const tileShadow = "0 0.04em 0.12em rgb(0 0 0 / 0.18), inset 0 0 0 0.012em rgb(0 0 0 / 0.06)"; function Slot({ slot, order, tick, reduceMotion, delay }: { slot: IconSlot; order: number; tick: number; reduceMotion: boolean; delay: number }) { const index = slot.icons.length ? tick % slot.icons.length : 0; const background = slot.backgrounds?.[index] ?? slot.backgrounds?.[0] ?? "#f4f4f5"; return ( <motion.span aria-hidden="true" className="relative mx-[0.1em] inline-grid size-[0.86em] -translate-y-[0.06em] place-items-center overflow-hidden rounded-[22%] align-middle [perspective:200px]" style={{ boxShadow: tileShadow }} initial={reduceMotion ? { opacity: 0 } : { scale: 0, rotate: -18, opacity: 0 }} animate={{ scale: 1, rotate: 0, opacity: 1, backgroundColor: background }} transition={reduceMotion ? { duration: 0.2, delay } : { type: "spring", stiffness: 420, damping: 16, delay: delay + 0.25 + order * 0.12, backgroundColor: { duration: 0.35 } }} > <AnimatePresence initial={false} mode="popLayout"> <motion.span key={index} className="grid size-full place-items-center" initial={reduceMotion ? { opacity: 0 } : { rotateX: -90, opacity: 0 }} animate={{ rotateX: 0, opacity: 1 }} exit={reduceMotion ? { opacity: 0 } : { rotateX: 90, opacity: 0 }} transition={{ duration: 0.38, ease: ease.out, delay: reduceMotion ? 0 : order * 0.08 }} > {slot.icons[index]} </motion.span> </AnimatePresence> </motion.span> ); } /** * A big headline with app-icon tiles set inline between its words. Words rise in, the tiles pop into * place one after another, then each slot flips to its next icon on an interval. The icons are * decorative; screen readers hear the words only. */ export function IconHeadline({ parts, as: Tag = "h1", interval = 2600, delay = 0, className }: IconHeadlineProps) { const reduceMotion = useReducedMotion() ?? false; const [tick, setTick] = useState(0); const cycling = interval > 0 && !reduceMotion && parts.some((p) => typeof p !== "string" && p.icons.length > 1); useEffect(() => { if (!cycling) return; let timer = 0; const start = window.setTimeout(() => { timer = window.setInterval(() => { if (!document.hidden) setTick((t) => t + 1); }, interval); }, delay * 1000 + 1200); return () => { window.clearTimeout(start); window.clearInterval(timer); }; }, [cycling, interval, delay]); let wordIndex = 0; let slotIndex = 0; const label = parts.filter((p): p is string => typeof p === "string").join(" "); return ( <Tag aria-label={label} className={cn("text-balance text-[clamp(2.25rem,7vw,4.5rem)] leading-[1.08] font-semibold tracking-[-0.03em] text-foreground", className)}> {parts.map((part, i) => { if (typeof part !== "string") { const order = slotIndex++; return <Slot key={`slot-${i}`} slot={part} order={order} tick={tick} reduceMotion={reduceMotion} delay={delay} />; } return ( <span key={`text-${i}`} aria-hidden="true"> {part.split(/(\s+)/).map((word, j) => { if (/^\s+$/.test(word) || !word) return word; const order = wordIndex++; return ( <motion.span key={j} className="inline-block" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: "0.3em", filter: "blur(6px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} transition={{ duration: reduceMotion ? 0.2 : 0.55, ease: ease.out, delay: delay + order * 0.05 }} > {word} </motion.span> ); })}{" "} </span> ); })} </Tag> ); }
Usage
import { IconHeadline } from "@/components/ui/icon-headline/icon-headline";
<IconHeadline
parts={[
"Where",
{ icons: [<RocketIcon key="r" />, <MusicIcon key="m" />], backgrounds: ["#f43f5e", "#8b5cf6"] },
"teams find designs that work",
]}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
parts* | (string | IconSlot)[] | – | Words and icon slots in order, e.g. ["Where", { icons: [...] }, "teams and agents"]. |
as | ElementType | "h1" | Heading element. |
interval | number | 2600 | Milliseconds between icon swaps; 0 keeps the first icon. |
delay | number | 0 | Seconds before the entrance starts. |
className | string | – | Classes for the heading. |
- Tiles are sized in em, so they scale with the heading's font size and sit on the text's line like a word.
- Each slot can hold one icon (it just pops in) or several (it flips through them, staggered across slots). Set `interval` to 0 to keep the first icon.
- Use your own icon art; the demo uses plain line icons on colored tiles rather than any real company's app icon.
Accessibility
- Screen readers hear the words only: the heading carries the text as its name, and the icon tiles are aria-hidden.
- Icons swap on a timer that pauses in hidden tabs. Under reduced motion the words and tiles fade in, and the icons don't cycle.