Fanned trio hero
Centered headline with a drawn underline over three fanned, gently floating product renders.
blocktrioanycta
$
npx shadcn@latest add @storekit/hero-triohero-trio
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-trio$
pnpm dlx shadcn@latest add @storekit/hero-trio$
yarn dlx shadcn@latest add @storekit/hero-trio$
bunx --bun shadcn@latest add @storekit/hero-trioSource
components/storekit/hero/hero-trio.tsx
125 lines
"use client"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Three product renders fanned under a centered headline. Each render drifts
on a slow 6s loop (off for reduced motion). One headline word gets a hand
drawn underline that strokes in once. */
const EASE = [0.23, 1, 0.32, 1] as const
const FAN = [
{ rotate: -8, x: 18, y: 12 },
{ rotate: 0, x: 0, y: -8 },
{ rotate: 8, x: -18, y: 12 },
]
type Cta = { label: string; href: string }
type Render = { image: string; title: string }
export function HeroTrio({
headlineStart = "Three daily habits.",
headlineAccent = "One",
headlineEnd = "easy routine.",
subcopy = "Greens in the morning, focus at noon, magnesium at night. Subscribe and save 15%.",
products = [],
primaryCta = { label: "Shop the trio", href: "#" },
secondaryCta = { label: "Build your routine", href: "#" },
className,
}: {
headlineStart?: string
headlineAccent?: string
headlineEnd?: string
subcopy?: string
products?: Render[]
primaryCta?: Cta
secondaryCta?: Cta
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("hero", "hero-trio")
const rise = (i: number) => ({
initial: { opacity: 0, y: reduce ? 0 : 8 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.45, ease: EASE, delay: reduce ? 0 : i * 0.06 },
})
const trio = products.slice(0, 3)
return (
<section ref={sk.ref} {...sk.attrs} className={cn("overflow-hidden bg-sk-paper text-sk-ink", className)}>
<div className="mx-auto flex w-full max-w-5xl flex-col items-center px-4 py-12 text-center sm:px-8 sm:py-16 lg:min-h-[480px]">
<motion.h1
{...rise(0)}
className="max-w-3xl text-balance font-sk-display font-semibold leading-[1.1] tracking-[-0.02em]"
style={{ fontSize: "clamp(2.1rem, 4.8vw, 3.75rem)" }}
>
{headlineStart}{" "}
<span className="relative inline-block whitespace-nowrap">
{headlineAccent}
<svg
aria-hidden
viewBox="0 0 200 12"
preserveAspectRatio="none"
className="absolute -bottom-[0.12em] left-0 h-[0.22em] w-full overflow-visible text-sk-accent"
>
<motion.path
d="M3 8.5C42 3.5 118 2.5 197 6.5"
fill="none"
stroke="currentColor"
strokeWidth={3.5}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.8, ease: EASE, delay: reduce ? 0 : 0.45 }}
/>
</svg>
</span>{" "}
{headlineEnd}
</motion.h1>
<motion.p {...rise(1)} className="mt-5 max-w-md text-[15px] leading-[1.5] text-sk-muted">
{subcopy}
</motion.p>
<div className="mt-10 flex w-full max-w-2xl items-end justify-center sm:mt-12">
{trio.map((p, i) => (
<motion.div
key={p.image + i}
initial={{ opacity: 0, y: reduce ? FAN[i].y : FAN[i].y + 24, rotate: reduce ? FAN[i].rotate : 0, x: FAN[i].x }}
animate={{ opacity: 1, y: FAN[i].y, rotate: FAN[i].rotate, x: FAN[i].x }}
transition={{ duration: 0.5, ease: EASE, delay: reduce ? 0 : 0.15 + i * 0.06 }}
className={cn("relative w-1/3 max-w-[220px]", i === 1 ? "z-10" : "z-0")}
>
<motion.div
animate={reduce ? undefined : { y: [0, -8, 0] }}
transition={reduce ? undefined : { duration: 6, ease: "easeInOut", repeat: Infinity, delay: i * 0.9 }}
className="aspect-[4/5] overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface"
>
<img src={p.image} alt={p.title} className="h-full w-full object-cover" />
</motion.div>
</motion.div>
))}
</div>
<motion.div {...rise(3)} className="mt-12 flex w-full flex-col justify-center gap-3 sm:w-auto sm:flex-row">
<a
href={primaryCta.href}
onClick={() => sk.emit("interact", "cta")}
className="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]"
>
{primaryCta.label}
</a>
<a
href={secondaryCta.href}
onClick={() => sk.emit("interact", "cta_secondary")}
className="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]"
>
{secondaryCta.label}
</a>
</motion.div>
</div>
</section>
)
}
Usage
Import from "@/components/storekit/hero/hero-trio" and pass your product data. The demo above exercises the full prop surface with sample values.


