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-gateage-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-gateSource
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.


