Fanned trio hero

Centered headline with a drawn underline over three fanned, gently floating product renders.

blocktrioanycta
$npx shadcn@latest add @storekit/hero-trio
hero-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-trio

Source

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.

Fanned trio hero, StoreKit