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

Source

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.

Cinemagraph micro-motion hero, StoreKit