Editorial magazine hero
Issue-style masthead, cover story with image and headline, and a numbered contents column of linked stories.
blockmagazineanycontent
$
npx shadcn@latest add @storekit/hero-magazinehero-magazine
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-magazine$
pnpm dlx shadcn@latest add @storekit/hero-magazine$
yarn dlx shadcn@latest add @storekit/hero-magazine$
bunx --bun shadcn@latest add @storekit/hero-magazineSource
components/storekit/hero/hero-magazine.tsx
130 lines
"use client"
import { motion, useReducedMotion } from "motion/react"
import { ArrowRight } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Editorial magazine hero. Reads like the cover of an issue: a masthead
rule with the issue number and season, a cover story with a big image and
headline, and a contents column of shorter stories with kicker, title and
read time. Every story is one link. The contents column numbers its items
like a table of contents. On mobile the cover leads and the contents list
follows as a ruled list. */
const EASE = [0.23, 1, 0.32, 1] as const
export type Story = { kicker?: string; title: string; dek?: string; href: string; image?: string; readTime?: string; focus?: string }
const COVER: Story = {
kicker: "Cover story",
title: "The slow morning, and why it beats a perfect routine",
dek: "Three dietitians on the small habits that actually stick, and the ones you can drop.",
href: "#",
image: "/placeholder.webp",
readTime: "8 min read",
}
const SAMPLE: Story[] = [
{ kicker: "Ingredients", title: "What 28 greens in one scoop really means", href: "#", readTime: "5 min", image: "/placeholder.webp" },
{ kicker: "Sleep", title: "Magnesium, explained without the hype", href: "#", readTime: "4 min", image: "/placeholder.webp" },
{ kicker: "Field notes", title: "Inside a third-party lab test", href: "#", readTime: "6 min", image: "/placeholder.webp" },
]
export function HeroMagazine({
masthead = "The Journal",
issue = "Issue 07",
season = "Autumn 2026",
cover = COVER,
stories = SAMPLE,
allHref = "#",
className,
}: {
masthead?: string
issue?: string
season?: string
cover?: Story
stories?: Story[]
allHref?: string
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("hero", "hero-magazine")
const rise = (i: number) => ({
initial: { opacity: 0, y: reduce ? 0 : 10 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.55, ease: EASE, delay: reduce ? 0 : i * 0.07 },
})
return (
<section ref={sk.ref} {...sk.attrs} className={cn("bg-sk-paper text-sk-ink", className)}>
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-8 sm:py-12">
<motion.header {...rise(0)} className="flex items-baseline justify-between gap-4 border-b-2 border-sk-ink pb-3">
<p className="font-sk-display text-[22px] font-semibold leading-none tracking-[-0.02em] sm:text-[28px]">{masthead}</p>
<p className="flex gap-3 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
<span>{issue}</span>
<span aria-hidden>/</span>
<span>{season}</span>
</p>
</motion.header>
<div className="mt-6 grid gap-8 lg:grid-cols-[1.55fr_1fr] lg:gap-10">
<motion.a
{...rise(1)}
href={cover.href}
onClick={() => sk.emit("interact", "cover")}
className="group block rounded-sk-card outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-4 focus-visible:ring-offset-sk-paper"
>
{cover.image ? (
<div className="relative aspect-[4/3] overflow-hidden rounded-sk-card bg-sk-surface sm:aspect-[16/10]">
<img src={cover.image} alt="" className="absolute inset-0 h-full w-full object-cover transition-transform duration-[900ms] ease-sk-out group-hover:scale-[1.025]" style={{ objectPosition: cover.focus }} />
</div>
) : null}
{cover.kicker ? <p className="mt-5 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-accent">{cover.kicker}</p> : null}
<h1 className="mt-2 text-balance font-sk-display font-semibold leading-[1.04] tracking-[-0.025em]" style={{ fontSize: "clamp(2rem, 4.4vw, 3.5rem)" }}>
<span className="bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_2px] bg-left-bottom bg-no-repeat transition-[background-size] duration-500 ease-sk-out group-hover:bg-[length:100%_2px]">
{cover.title}
</span>
</h1>
{cover.dek ? <p className="mt-3 max-w-xl text-[16px] leading-[1.5] text-sk-muted">{cover.dek}</p> : null}
{cover.readTime ? <p className="mt-3 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{cover.readTime}</p> : null}
</motion.a>
<motion.nav {...rise(2)} aria-label="In this issue">
<p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">In this issue</p>
<ol className="mt-3 divide-y divide-sk-line border-y border-sk-line">
{stories.map((s, i) => (
<li key={s.title}>
<a
href={s.href}
onClick={() => sk.emit("interact", "story", i)}
className="group flex items-start gap-4 py-4 outline-none focus-visible:bg-sk-surface"
>
<span className="w-6 shrink-0 pt-0.5 font-sk-mono text-[12px] tabular-nums text-sk-muted">{String(i + 2).padStart(2, "0")}</span>
<span className="min-w-0 flex-1">
{s.kicker ? <span className="block font-sk-mono text-[10px] uppercase tracking-[0.14em] text-sk-muted">{s.kicker}</span> : null}
<span className="mt-1 block text-balance font-sk-display text-[18px] font-semibold leading-[1.2] tracking-[-0.01em] underline decoration-transparent underline-offset-4 transition-[text-decoration-color] duration-200 group-hover:decoration-sk-ink/40">
{s.title}
</span>
{s.readTime ? <span className="mt-1.5 block text-[12px] text-sk-muted">{s.readTime}</span> : null}
</span>
{s.image ? <img src={s.image} alt="" className="h-16 w-16 shrink-0 rounded-sk-chip bg-sk-surface object-cover sm:h-20 sm:w-20" style={{ objectPosition: s.focus }} /> : null}
</a>
</li>
))}
</ol>
<a
href={allHref}
onClick={() => sk.emit("interact", "all_stories")}
className="group mt-4 inline-flex min-h-11 items-center gap-1.5 text-sm font-medium outline-none focus-visible:underline"
>
Read the full issue
<ArrowRight size={15} aria-hidden className="transition-transform duration-300 ease-sk-out group-hover:translate-x-0.5" />
</a>
</motion.nav>
</div>
</div>
</section>
)
}
Usage
Import from "@/components/storekit/hero/hero-magazine" and pass your product data. The demo above exercises the full prop surface with sample values.
