Editorial scene hero

Full-bleed scene photograph with a directional scrim, bottom-left headline, CTA pair and a trust chip.

blockeditorialanycta
$npx shadcn@latest add @storekit/hero-editorial
hero-editorial

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-editorial
$pnpm dlx shadcn@latest add @storekit/hero-editorial
$yarn dlx shadcn@latest add @storekit/hero-editorial
$bunx --bun shadcn@latest add @storekit/hero-editorial

Source

components/storekit/hero/hero-editorial.tsx
118 lines
"use client"

import { motion, useReducedMotion } from "motion/react"
import { BadgeCheck } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Full-bleed editorial hero. A scene photograph with a left-to-right scrim so
   the headline reads in the negative space, bottom-left. Text on photography
   uses a fixed dark scrim and fixed light text, so contrast holds for light
   and dark brands alike. From sm to lg the scrim runs bottom-up and the copy
   sits at the bottom. Under sm the photo stacks on top with no text over it
   and the copy follows on the paper token. */

const EASE = [0.23, 1, 0.32, 1] as const

type Cta = { label: string; href: string }

export function HeroEditorial({
  image = "/placeholder.webp",
  imageAlt = "",
  eyebrow = "New season routines",
  headline = "Clean ingredients. Shipped the same day.",
  subcopy = "Daily essentials that taste good, with third-party test results for every batch.",
  primaryCta = { label: "Shop the range", href: "#" },
  secondaryCta = { label: "See lab results", href: "#" },
  chipLabel = "Third-party tested",
  chipHref = "#",
  className,
}: {
  image?: string
  imageAlt?: string
  eyebrow?: string
  headline?: string
  subcopy?: string
  primaryCta?: Cta
  secondaryCta?: Cta
  chipLabel?: string
  chipHref?: string
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("hero", "hero-editorial")
  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 : 0.15 + i * 0.06 },
  })

  return (
    <section
      ref={sk.ref}
      {...sk.attrs}
      className={cn("relative isolate flex flex-col overflow-hidden bg-sk-paper sm:min-h-[600px] sm:flex-row sm:bg-[rgb(0_0_0)]", className)}
    >
      <motion.img
        src={image}
        alt={imageAlt}
        initial={{ opacity: 0, scale: reduce ? 1 : 1.04 }}
        animate={{ opacity: 1, scale: 1 }}
        transition={{ duration: 0.9, ease: EASE }}
        className="aspect-[4/5] w-full object-cover object-[72%_center] sm:absolute sm:inset-0 sm:-z-10 sm:aspect-auto sm:h-full"
      />
      <div
        aria-hidden
        className="absolute inset-0 -z-10 hidden bg-[linear-gradient(0deg,rgb(0_0_0/0.9),rgb(0_0_0/0.78)_45%,rgb(0_0_0/0.25)_75%,rgb(0_0_0/0.05))] sm:block lg:hidden"
      />
      <div
        aria-hidden
        className="absolute inset-0 -z-10 hidden bg-[linear-gradient(90deg,rgb(0_0_0/0.66),rgb(0_0_0/0.4)_38%,rgb(0_0_0/0)_70%)] lg:block"
      />

      <div className="mx-auto flex w-full max-w-6xl items-end px-4 pb-12 pt-8 sm:px-8 sm:pb-14 sm:pt-24 lg:pb-20">
        <div className="flex max-w-xl flex-col text-sk-ink sm:block sm:text-[rgb(255_255_255)] sm:[text-shadow:0_1px_16px_rgb(0_0_0/0.35)]">
          <motion.p {...rise(0)} className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted sm:text-[rgb(255_255_255/0.72)]">
            {eyebrow}
          </motion.p>
          <motion.h1
            {...rise(1)}
            className="mt-4 text-balance font-sk-display font-semibold leading-[1.1] tracking-[-0.02em]"
            style={{ fontSize: "clamp(2.25rem, 5vw, 4rem)" }}
          >
            {headline}
          </motion.h1>
          <motion.p {...rise(2)} className="mt-4 max-w-md text-[15px] leading-[1.5] text-sk-muted sm:text-[rgb(255_255_255/0.78)]">
            {subcopy}
          </motion.p>
          <motion.div {...rise(3)} className="mt-8 flex flex-col gap-3 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] sm:focus-visible:ring-[rgb(255_255_255)] sm:focus-visible:ring-offset-[rgb(0_0_0)]"
            >
              {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.05] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97] sm:border-[rgb(255_255_255/0.4)] sm:text-[rgb(255_255_255)] sm:hover:bg-[rgb(255_255_255/0.1)] sm:focus-visible:ring-[rgb(255_255_255)] sm:focus-visible:ring-offset-0"
            >
              {secondaryCta.label}
            </a>
          </motion.div>
          <motion.a
            {...rise(4)}
            href={chipHref}
            onClick={() => sk.emit("interact", "chip")}
            className="mt-6 inline-flex min-h-11 items-center gap-2 self-start rounded-sk-chip border border-sk-line px-3.5 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-ink outline-none transition-transform duration-200 ease-sk-out [text-shadow:none] hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-sk-accent sm:absolute sm:right-8 sm:top-8 sm:mt-0 sm:border-[rgb(255_255_255/0.28)] sm:bg-[rgb(0_0_0/0.35)] sm:text-[rgb(255_255_255)] sm:focus-visible:ring-[rgb(255_255_255)]"
          >
            <BadgeCheck size={14} aria-hidden />
            {chipLabel}
          </motion.a>
        </div>
      </div>
    </section>
  )
}

Usage

Import from "@/components/storekit/hero/hero-editorial" and pass your product data. The demo above exercises the full prop surface with sample values.

Editorial scene hero, StoreKit