Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import 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 } },
      };
    }
  3. 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

PropTypeDefaultDescription
parts*(string | IconSlot)[]–Words and icon slots in order, e.g. ["Where", { icons: [...] }, "teams and agents"].
asElementType"h1"Heading element.
intervalnumber2600Milliseconds between icon swaps; 0 keeps the first icon.
delaynumber0Seconds before the entrance starts.
classNamestring–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.