Cinemagraph micro-motion hero
One still photo with a single living region (sway, breathe or glint) isolated by a soft mask, no video file, with a pause control.
blockcinemagraphanycta
$
npx shadcn@latest add @storekit/hero-cinemagraphhero-cinemagraph
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-cinemagraph$
pnpm dlx shadcn@latest add @storekit/hero-cinemagraph$
yarn dlx shadcn@latest add @storekit/hero-cinemagraph$
bunx --bun shadcn@latest add @storekit/hero-cinemagraphSource
components/storekit/hero/hero-cinemagraph.tsx
157 lines
"use client"
import { useEffect, useRef, useState } from "react"
import { motion, useInView, useReducedMotion } from "motion/react"
import { Pause, Play } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Cinemagraph micro-motion hero. One still photograph, one living detail.
No video file: a soft-edged mask isolates a `region` of the same image and
only that region moves (a slow sway, a breath or a passing glint), so the
scene reads as a photo with a single loop in it. The loop stops when the
hero leaves the viewport, a pause control satisfies WCAG 2.2.2, and with
reduced motion the image is simply still. Copy sits on a paper-token fade
over the quiet side of the frame on lg and stacks below the image on
mobile. */
const EASE = [0.23, 1, 0.32, 1] as const
type Cta = { label: string; href: string }
type Region = { x: number; y: number; rx: number; ry: number }
const PRIMARY =
"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]"
const SECONDARY =
"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]"
export function HeroCinemagraph({
image = "/placeholder.webp",
imageAlt = "",
region = { x: 44, y: 30, rx: 14, ry: 28 },
effect = "sway",
period = 7,
focus = "70% center",
eyebrow = "Slow mornings",
headline = "The quiet part of your routine.",
subcopy = "One scoop, a glass of water and a minute of sun. Daily essentials that fit the way you already start the day.",
primaryCta = { label: "Shop the routine", href: "#" },
secondaryCta = { label: "Our ingredients", href: "#" },
className,
}: {
image?: string
imageAlt?: string
/** The living area, as an ellipse in percent of the frame. */
region?: Region
/** sway: drifting shadows or leaves. breathe: steam, fabric, water. glint: light passing over glass or foil. */
effect?: "sway" | "breathe" | "glint"
/** Seconds per loop. Slower reads as calmer. */
period?: number
/** object-position for the photo, so the subject stays in frame on narrow screens. */
focus?: string
eyebrow?: string
headline?: string
subcopy?: string
primaryCta?: Cta
secondaryCta?: Cta
className?: string
}) {
const prefersReduced = useReducedMotion()
// Server HTML assumes motion; read the preference only after hydration so markup matches.
const [hydrated, setHydrated] = useState(false)
useEffect(() => setHydrated(true), [])
const reduce = hydrated && !!prefersReduced
const sk = useSkBlock("hero", "hero-cinemagraph")
const frame = useRef<HTMLDivElement>(null)
const inView = useInView(frame, { amount: 0.2 })
const [paused, setPaused] = useState(false)
const live = !reduce && !paused && inView
const mask = `radial-gradient(ellipse ${region.rx}% ${region.ry}% at ${region.x}% ${region.y}%, rgb(0 0 0) 35%, transparent 100%)`
const loop = { duration: period, ease: "easeInOut" as const, repeat: Infinity, repeatType: "mirror" as const }
const motionFor =
effect === "sway"
? { animate: live ? { x: [0, 7, -3], skewX: [0, 0.8, -0.4] } : { x: 0, skewX: 0 } }
: effect === "breathe"
? { animate: live ? { scale: [1, 1.025, 1.01], y: [0, -4, 0] } : { scale: 1, y: 0 } }
: { animate: { x: 0 } }
const rise = (i: number) => ({
initial: { opacity: 0, y: reduce ? 0 : 8 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5, ease: EASE, delay: reduce ? 0 : 0.1 + i * 0.06 },
})
return (
<section ref={sk.ref} {...sk.attrs} className={cn("relative isolate overflow-hidden bg-sk-paper text-sk-ink", className)}>
<div ref={frame} className="relative aspect-[4/3] w-full sm:aspect-[16/9] lg:absolute lg:inset-0 lg:-z-10 lg:aspect-auto">
<img src={image} alt={imageAlt} className="absolute inset-0 h-full w-full object-cover" style={{ objectPosition: focus }} />
{!reduce ? (
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden" style={{ maskImage: mask, WebkitMaskImage: mask }}>
{effect === "glint" ? (
<motion.div
className="absolute inset-y-0 -left-1/3 w-1/3 bg-[linear-gradient(100deg,transparent,rgb(255_255_255/0.38),transparent)] mix-blend-soft-light"
animate={live ? { x: ["0%", "400%"] } : undefined}
transition={{ duration: period, ease: "easeInOut", repeat: Infinity, repeatDelay: period * 0.4 }}
/>
) : (
<motion.img
src={image}
alt=""
{...motionFor}
transition={loop}
className="absolute inset-0 h-full w-full object-cover will-change-transform"
style={{ objectPosition: focus, transformOrigin: `${region.x}% ${region.y + region.ry}%` }}
/>
)}
</div>
) : null}
<div
aria-hidden
className="pointer-events-none absolute inset-0 hidden bg-[linear-gradient(90deg,rgb(var(--sk-paper)/0.94),rgb(var(--sk-paper)/0.72)_34%,rgb(var(--sk-paper)/0)_58%)] lg:block"
/>
{!reduce ? (
<button
type="button"
onClick={() => {
setPaused((p) => !p)
sk.emit("interact", paused ? "motion_play" : "motion_pause")
}}
aria-label={paused ? "Play background motion" : "Pause background motion"}
aria-pressed={paused}
className="absolute bottom-3 right-3 grid h-11 w-11 place-items-center rounded-full bg-[rgb(0_0_0/0.45)] text-[rgb(255_255_255)] outline-none backdrop-blur-sm transition-[background-color,transform] duration-200 ease-sk-out hover:bg-[rgb(0_0_0/0.6)] focus-visible:ring-2 focus-visible:ring-[rgb(255_255_255)] active:scale-[0.97] lg:bottom-6 lg:right-6"
>
{paused ? <Play size={15} aria-hidden className="translate-x-px" /> : <Pause size={15} aria-hidden />}
</button>
) : null}
</div>
<div className="mx-auto flex w-full max-w-6xl px-4 py-10 sm:px-8 sm:py-12 lg:min-h-[600px] lg:items-center lg:py-20">
<div className="max-w-md">
<motion.p {...rise(0)} className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
{eyebrow}
</motion.p>
<motion.h1
{...rise(1)}
className="mt-4 text-balance font-sk-display font-semibold leading-[1.06] tracking-[-0.02em]"
style={{ fontSize: "clamp(2.25rem, 4.6vw, 3.75rem)" }}
>
{headline}
</motion.h1>
<motion.p {...rise(2)} className="mt-5 text-[15px] leading-[1.55] text-sk-muted">
{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={PRIMARY}>
{primaryCta.label}
</a>
<a href={secondaryCta.href} onClick={() => sk.emit("interact", "cta_secondary")} className={SECONDARY}>
{secondaryCta.label}
</a>
</motion.div>
</div>
</div>
</section>
)
}
Usage
Set `region` over the area that should move (shadows, steam, liquid). Unlike hero-video it needs no clip; it stops offscreen and under reduced motion.
