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-typehero-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-typeSource
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.


