Age gate

Full-screen 18+ interstitial that fades out on confirm and remembers the answer for 30 days.

componentage-gateanyage-verification
$npx shadcn@latest add @storekit/age-gate
age-gate

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

Source

components/storekit/compliance/age-gate.tsx
200 lines
"use client"

import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

type Sk = ReturnType<typeof useSkBlock>

/** Mounts with the gate so the view event fires when it actually appears. */
function Tracked({ children }: { children: (sk: Sk) => React.ReactNode }) {
  const sk = useSkBlock("compliance", "age-gate")
  return <>{children(sk)}</>
}

/* Full-screen age interstitial. A modal dialog with scroll lock and a focus
   trap; there is no Escape or backdrop exit by design. Confirming fades it out
   and stores an expiry in localStorage for `rememberDays`. Declining shows a
   blocked state with a way back. Set `contained` to cover only the nearest
   relative parent for previews; it then skips the page scroll lock, initial
   focus, and focus trap so the surrounding page stays usable. */

const ring =
  "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"

export function AgeGate({
  brandName = "Morrow",
  markSrc,
  minAge = 18,
  message = "Some products on this site are only for adults. Confirm your age to continue.",
  rememberDays = 30,
  storageKey = "sk-age-verified",
  contained = false,
  onConfirm,
  className,
}: {
  brandName?: string
  markSrc?: string
  minAge?: number
  /** One line under the heading explaining why you ask. */
  message?: string
  rememberDays?: number
  storageKey?: string
  contained?: boolean
  onConfirm?: () => void
  className?: string
}) {
  const reduce = useReducedMotion()
  const [open, setOpen] = useState(false)
  const [denied, setDenied] = useState(false)
  const ref = useRef<HTMLDivElement>(null)
  const titleId = useId()
  const descId = useId()

  useEffect(() => {
    let valid = false
    try {
      const until = Number(window.localStorage.getItem(storageKey))
      valid = Number.isFinite(until) && until > Date.now()
    } catch {}
    setOpen(!valid)
  }, [storageKey])

  useEffect(() => {
    if (!open || contained) return
    const prev = document.body.style.overflow
    document.body.style.overflow = "hidden"
    const raf = requestAnimationFrame(() => ref.current?.querySelector<HTMLElement>("button")?.focus())
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Tab" || !ref.current) return
      const items = Array.from(ref.current.querySelectorAll<HTMLElement>("button:not([disabled]),a[href]"))
      if (!items.length) return
      const first = items[0]
      const last = items[items.length - 1]
      if (!ref.current.contains(document.activeElement)) {
        e.preventDefault()
        first.focus()
      } else if (e.shiftKey && document.activeElement === first) {
        e.preventDefault()
        last.focus()
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault()
        first.focus()
      }
    }
    document.addEventListener("keydown", onKey)
    return () => {
      cancelAnimationFrame(raf)
      document.body.style.overflow = prev
      document.removeEventListener("keydown", onKey)
    }
  }, [open, denied, contained])

  const confirm = (sk: Sk) => {
    sk.emit("convert", "age_confirm")
    try {
      window.localStorage.setItem(storageKey, String(Date.now() + rememberDays * 86400000))
    } catch {}
    setOpen(false)
    onConfirm?.()
  }

  return (
    <AnimatePresence>
      {open ? (
        <Tracked key="gate">
          {(sk) => (
            <motion.div
              ref={(el) => {
                ref.current = el
                sk.ref.current = el
              }}
              {...sk.attrs}
              role="dialog"
              aria-modal={contained ? undefined : true}
              aria-labelledby={titleId}
              aria-describedby={descId}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
              className={cn(contained ? "absolute" : "fixed", "inset-0 z-50 grid place-items-center bg-sk-paper px-4", className)}
            >
              <motion.div
                initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: reduce ? 0 : -8 }}
                transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.05 }}
                className="flex w-full max-w-sm flex-col items-center text-center"
              >
                {markSrc ? <img src={markSrc} alt="" className="h-10 w-10 object-contain" /> : null}
                <p className="mt-4 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{brandName}</p>
                <AnimatePresence mode="wait" initial={false}>
                  {denied ? (
                    <motion.div
                      key="denied"
                      initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0 }}
                      transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
                      className="flex flex-col items-center"
                    >
                      <h2 id={titleId} className="mt-2 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance">
                        Access restricted
                      </h2>
                      <p id={descId} className="mt-3 text-sm leading-[1.5] text-sk-muted">
                        This store is only available to adults {minAge} and older.
                      </p>
                      <button
                        type="button"
                        onClick={() => setDenied(false)}
                        className={cn("mt-6 inline-flex min-h-11 items-center rounded-sk-btn px-3 text-sm text-sk-ink underline underline-offset-4", ring)}
                      >
                        I answered by mistake
                      </button>
                    </motion.div>
                  ) : (
                    <motion.div
                      key="ask"
                      initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0 }}
                      transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
                      className="flex w-full flex-col items-center"
                    >
                      <h2 id={titleId} className="mt-2 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance">
                        You must be {minAge} or older
                      </h2>
                      <p id={descId} className="mt-3 text-sm leading-[1.5] text-sk-muted">
                        {message}
                      </p>
                      <div className="mt-6 grid w-full gap-2 sm:grid-cols-2">
                        <button
                          type="button"
                          onClick={() => confirm(sk)}
                          className={cn("inline-flex h-11 items-center justify-center rounded-sk-btn bg-sk-accent px-5 text-sm font-medium text-sk-accent-ink transition-[transform,filter] duration-200 hover:brightness-[1.06] active:scale-[0.97]", ring)}
                        >
                          I am {minAge} or older
                        </button>
                        <button
                          type="button"
                          onClick={() => setDenied(true)}
                          className={cn("inline-flex h-11 items-center justify-center rounded-sk-btn border border-sk-line px-5 text-sm font-medium text-sk-ink transition-[transform,background-color] duration-200 hover:bg-sk-ink/[0.04] active:scale-[0.97]", ring)}
                        >
                          I am under {minAge}
                        </button>
                      </div>
                      <p className="mt-4 text-xs text-sk-muted">We remember your answer for {rememberDays} days.</p>
                    </motion.div>
                  )}
                </AnimatePresence>
              </motion.div>
            </motion.div>
          )}
        </Tracked>
      ) : null}
    </AnimatePresence>
  )
}

Usage

Mount once in your root layout. contained is for previews and skips the page scroll lock and focus trap.