Kinetic type hero

Oversized display type is the visual: a spring-swapped kinetic word in a fixed slot and inline product pills, with a pause control.

blocktypeanycta
$npx shadcn@latest add @storekit/hero-type
hero-type

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/hero-type
$pnpm dlx shadcn@latest add @storekit/hero-type
$yarn dlx shadcn@latest add @storekit/hero-type
$bunx --bun shadcn@latest add @storekit/hero-type

Source

components/storekit/hero/hero-type.tsx
152 lines
"use client"

import { useEffect, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Pause, Play } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Typography-first hero. Oversized display type is the visual. Line one
   ends in a kinetic word that swaps on a spring every few seconds; line two
   carries small inline product pills set into the sentence. The rotating
   word sits in a fixed-width slot sized to the longest word, so nothing
   reflows. Screen readers get the whole sentence once, the animated copy is
   hidden from them, there is a pause control, and with reduced motion the
   first word simply stays. */

const EASE = [0.23, 1, 0.32, 1] as const

type Cta = { label: string; href: string }

const PRIMARY =
  "sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn bg-sk-accent px-7 text-[15px] font-medium text-sk-accent-ink outline-none transition-[filter,transform] duration-200 ease-sk-out hover:brightness-[1.06] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"
const SECONDARY =
  "sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn border border-sk-line px-7 text-[15px] font-medium text-sk-ink outline-none transition-[background-color,transform] duration-200 ease-sk-out hover:bg-sk-ink/[0.04] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"

export function HeroType({
  lead = "Feel",
  words = ["sharper", "calmer", "lighter", "steadier"],
  tail = "by breakfast.",
  pills = [],
  interval = 2600,
  subcopy = "Six daily essentials. Clean ingredients, third-party tested, made to taste good.",
  primaryCta = { label: "Shop the range", href: "#" },
  secondaryCta = { label: "Take the quiz", href: "#" },
  className,
}: {
  lead?: string
  /** The kinetic word. One word shows statically. */
  words?: string[]
  tail?: string
  /** Small inline images set into line two, before the tail. */
  pills?: { src: string; alt: string }[]
  /** Milliseconds per word. */
  interval?: number
  subcopy?: string
  primaryCta?: Cta
  secondaryCta?: Cta
  className?: string
}) {
  const prefersReduced = useReducedMotion()
  // Server HTML assumes motion; read the preference only after hydration so markup matches.
  const [hydrated, setHydrated] = useState(false)
  useEffect(() => setHydrated(true), [])
  const reduce = hydrated && !!prefersReduced
  const sk = useSkBlock("hero", "hero-type")
  const [i, setI] = useState(0)
  const [paused, setPaused] = useState(false)
  const kinetic = words.length > 1 && !reduce

  useEffect(() => {
    if (!kinetic || paused) return
    const id = setInterval(() => setI((n) => (n + 1) % words.length), interval)
    return () => clearInterval(id)
  }, [kinetic, paused, interval, words.length])

  const sentence = `${lead} ${words.length > 1 ? words.join(", ") : words[0]} ${tail}`
  const longest = words.reduce((a, b) => (b.length > a.length ? b : a), "")

  return (
    <section ref={sk.ref} {...sk.attrs} className={cn("overflow-hidden bg-sk-paper text-sk-ink", className)}>
      <div className="mx-auto w-full max-w-6xl px-4 pb-12 pt-12 sm:px-8 sm:pb-16 sm:pt-16 lg:pb-20 lg:pt-20">
        <h1 className="font-sk-display font-semibold leading-[0.95] tracking-[-0.045em]" style={{ fontSize: "clamp(3rem, 10.5vw, 9rem)" }}>
          <span className="sr-only">{sentence}</span>
          <span aria-hidden className="block">
            <motion.span initial={{ opacity: 0, y: reduce ? 0 : 24 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease: EASE }} className="inline-block">
              {lead}
            </motion.span>{" "}
            <span className="relative inline-grid overflow-hidden whitespace-nowrap pb-[0.08em] align-bottom text-sk-accent">
              <span className="invisible col-start-1 row-start-1">{longest}</span>
              <AnimatePresence initial={false} mode="popLayout">
                <motion.span
                  key={words[i]}
                  initial={{ y: "100%", opacity: 0, filter: "blur(4px)" }}
                  animate={{ y: 0, opacity: 1, filter: "blur(0px)" }}
                  exit={{ y: "-90%", opacity: 0, filter: "blur(4px)" }}
                  transition={{ type: "spring", stiffness: 210, damping: 26, mass: 0.9 }}
                  className="col-start-1 row-start-1"
                >
                  {words[i]}
                </motion.span>
              </AnimatePresence>
            </span>
          </span>
          <motion.span
            aria-hidden
            initial={{ opacity: 0, y: reduce ? 0 : 24 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.7, ease: EASE, delay: reduce ? 0 : 0.08 }}
            className="mt-[0.06em] flex flex-wrap items-center gap-x-[0.22em]"
          >
            {pills.length ? (
              <span className="inline-flex items-center">
                {pills.slice(0, 3).map((p, n) => (
                  <motion.img
                    key={p.src + n}
                    src={p.src}
                    alt=""
                    initial={{ scale: reduce ? 1 : 0.6, opacity: 0 }}
                    animate={{ scale: 1, opacity: 1 }}
                    transition={{ type: "spring", stiffness: 300, damping: 22, delay: reduce ? 0 : 0.25 + n * 0.07 }}
                    whileHover={reduce ? undefined : { y: -6, rotate: n % 2 ? 4 : -4 }}
                    className={cn("h-[0.78em] w-[0.78em] rounded-full border-[3px] border-sk-paper bg-sk-surface object-cover shadow-[0_10px_30px_-12px_rgb(var(--sk-ink)/0.45)]", n > 0 && "-ml-[0.2em]")}
                  />
                ))}
              </span>
            ) : null}
            <span>{tail}</span>
          </motion.span>
        </h1>

        <motion.div
          initial={{ opacity: 0, y: reduce ? 0 : 8 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.5, ease: EASE, delay: reduce ? 0 : 0.2 }}
          className="mt-10 flex flex-col gap-6 border-t border-sk-line pt-6 md:flex-row md:items-center md:justify-between"
        >
          <p className="max-w-md text-[15px] leading-[1.55] text-sk-muted">{subcopy}</p>
          <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
            <a href={primaryCta.href} onClick={() => sk.emit("interact", "cta")} className={PRIMARY}>
              {primaryCta.label}
            </a>
            <a href={secondaryCta.href} onClick={() => sk.emit("interact", "cta_secondary")} className={SECONDARY}>
              {secondaryCta.label}
            </a>
            {kinetic ? (
              <button
                type="button"
                onClick={() => setPaused((p) => !p)}
                aria-pressed={paused}
                aria-label={paused ? "Play headline animation" : "Pause headline animation"}
                className="grid h-11 w-11 shrink-0 place-items-center self-start rounded-full sm:self-center text-sk-muted outline-none transition-colors hover:bg-sk-ink/[0.05] hover:text-sk-ink focus-visible:ring-2 focus-visible:ring-sk-accent"
              >
                {paused ? <Play size={15} aria-hidden /> : <Pause size={15} aria-hidden />}
              </button>
            ) : null}
          </div>
        </motion.div>
      </div>
    </section>
  )
}

Usage

One entry in `words` makes it static. The full sentence is read once by screen readers.

Kinetic type hero, StoreKit