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-magazine
hero-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-magazine

Source

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.

Editorial magazine hero, StoreKit