Blocks

Age Gate

A date-of-birth gate with three explicit boxes, a real calendar behind them, and a minimum-age rule whose refusal is honest and always offers a way out.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CalendarDays, Check, LogOut, ShieldAlert, ShieldCheck, TriangleAlert } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

/* -------------------------------------------------------------------- types */

export type AgeGateField = "day" | "month" | "year"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/age-gate.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AgeGate" block with lucide-react and the
shadcn Button / Input / Label primitives; cn() (clsx + tailwind-merge) for
classes, no other dependency. It is the panel that asks for a date of birth
before letting someone into an age-restricted site: three explicit boxes, a real
calendar behind them, one minimum-age rule, and a refusal that behaves like an
adult.

Say the limits out loud in the README and in the copy you ship: a self-declared
date of birth is a statement of intent, not identification. Anyone willing to
type a different year gets in. This component is worth shipping because it
records that the question was asked and answered, and because it refuses the
input that a careless implementation would silently "fix" — it is not, and
cannot be, identity verification. If you need that, call a verification service
and keep this panel as the front door.

Contract
- Props: { now; timeZone?; locale?; minimumAge?; maximumAge?; fieldOrder?;
  defaultValue?; defaultPhase?; defaultRemember?; showRemember?;
  allowCorrection?; autoAdvance?; exitHref?; onExit?; onVerified; onRefused?;
  onRememberChange?; copy?; mark? }. forwardRef<HTMLElement>, extends
  Omit<React.HTMLAttributes<HTMLElement>, "defaultValue" | "onSubmit">, rest
  spread on the root <section>.
- `now` is a REQUIRED ISO instant and is the only clock. Never call Date.now():
  a render-time clock read makes the same draft produce two different verdicts
  and desyncs SSR from hydration. `timeZone` (default "UTC") turns that instant
  into the calendar day the age is measured against.
- `onVerified(result)` is REQUIRED — a gate whose success does nothing is
  theatre with no second act. result = { birthDate: {year, month, day}; age;
  remember }. `onRefused(refusal)` fires on every rejected submit, field-level
  ones included, so a host can measure friction; filter on refusal.code.
- CivilDate is {year, month, day}: a birthday is a calendar fact, so it carries
  no time and no offset. The draft is three STRINGS, because "07" and "7" are
  different keystrokes and the difference matters while typing.
- Refusal codes, as a closed union: "incomplete" | "year-digits" |
  "month-range" | "day-range" | "impossible-date" | "future" | "implausible" |
  "no-clock" | "underage". Each carries { code; field: "day"|"month"|"year"|null;
  age: number|null } — `field` is where focus goes, `age` exists only on
  "underage" because every other code fails before an age can be computed.
- Export the whole verdict as one pure function,
  checkAgeGate(draft, { today, minimumAge, maximumAge, order }) ->
  { ok: true; birthDate; age } | { ok: false; refusal }, plus isLeapYear,
  daysInMonth, ageOn and civilDateIn. A server route re-runs the identical rule
  instead of growing a second opinion about who is old enough.
- copy is a per-string override map with {token} slots ({minimumAge},
  {maximumAge}, {monthName}, {monthDays}, {birthDate}). Merge key by key, not by
  spreading: an explicit undefined in a partial translation must not erase a
  default string.

Behavior — the maths
- daysInMonth is a table lookup with a leap-year branch (year % 4 === 0 &&
  year % 100 !== 0) || year % 400 === 0. NEVER new Date(year, month, day): that
  constructor rolls 30 February into 2 March and rewrites years 0-99 as 19xx —
  the two silent corrections this component exists to refuse. Guard the table
  with Number.isInteger, because NaN fails both range comparisons and would
  index it into undefined.
- ageOn(birth, today) is civil-calendar arithmetic, never milliseconds: age =
  today.year - birth.year, minus one if today.month < birth.month or (equal
  months and today.day < birth.day). No daylight-saving hour can move a birthday
  across midnight this way.
- Leap-day rule: a 29 February birthday completes its year on 1 March in a
  common year, because on 28 February the month matches and the day is short.
  Some jurisdictions read it as 28 February; that is one `<` to change and the
  whole component follows. Document whichever you ship.
- Validation order, first failure wins: empty box (in VISUAL order, so the
  reported box is the first one the eye lands on) -> year not exactly four
  digits -> month/day not 1-2 digits -> month outside 1-12 -> day outside 1-31
  -> day beyond daysInMonth (30 February, 31 April) -> no usable clock -> date
  in the future -> year older than maximumAge -> age below minimumAge.
- "96" is NEVER expanded to 1996. Guessing a century is exactly how a gate lets
  through the person it was built to stop.
- If `now` does not parse, civilDateIn returns null and the check returns the
  "no-clock" refusal. The gate FAILS CLOSED: no clock, no verdict, no entry.
- fieldOrder "auto" (the default) reads the order out of the locale's own
  numeric pattern with Intl.DateTimeFormat(locale, {day, month, year} numeric)
  .formatToParts, mapping the part types to "dmy" | "mdy" | "ymd"; a malformed
  tag throws and falls back to day-first. "dmy" | "mdy" | "ymd" override it.
- civilDateIn pins the calendar and digits in the LOCALE TAG
  ("en-US-u-ca-gregory-nu-latn"), not in the options bag: on a host whose
  default calendar is Buddhist the year comes back as 2569 and every visitor is
  543 years old. An unknown IANA zone throws a RangeError at construction —
  catch it and retry in UTC, because a config typo must not take the gate down.

Behavior — state, in one boolean plus the draft
- Internal state is only: the draft, a phase ("asking" | "attempted" |
  "decided") and the remember checkbox. The VERDICT is derived by calling
  checkAgeGate on every render, never stored. That is why no combination of
  props can show a confirmation panel above a date that fails: a gate mounted
  with defaultPhase="decided" and a typo'd draft renders the form with the field
  error instead.
- View: phase !== "decided" -> the form; decided + ok -> the confirmation panel;
  decided + "underage" -> the refusal panel; decided + any field-level code ->
  the form with the message shown.
- Field-level refusals are recoverable: the boxes stay, the message re-derives
  live as the visitor types, and it clears itself the moment the date becomes
  real. Only "underage" replaces the form.
- Submitting is one-shot per decision: a ref read AND written synchronously
  inside the submit handler, because a state flag is only visible after a
  re-render and a held Enter key repeats faster than that. Recoverable refusals
  leave the lock open; a terminal decision closes it until "I typed the wrong
  date" reopens it.
- Callbacks fire from the handler, never from a render or an effect. A gate that
  mounts already decided (a host restoring its own record) announces nothing and
  calls nothing.

Behavior — keyboard, focus and ARIA
- Root <section aria-labelledby> pointing at the h2, which every view supplies
  with the same id. A real <form noValidate onSubmit>, so Enter from any box
  submits (implicit submission) and browser validation never competes with the
  rule above.
- The three boxes are <fieldset> with an sr-only <legend> ("Date of birth"):
  they are one answer, so they share one message, referenced by aria-describedby
  from all three. aria-invalid marks only the offending box.
- type="text" + inputMode="numeric" + maxLength, not type="number": number
  inputs bring spinners, accept "e", and throw on setSelectionRange — which the
  caret handling below needs. Input is sanitised to digits on every change.
- autocomplete="bday-day" / "bday-month" / "bday-year" — real autofill tokens,
  so a browser that stored a birthday can fill all three.
- Keyboard map: Tab / Shift+Tab across boxes, checkbox, submit (they are plain
  controls, not a composite widget, so no roving tabindex). Enter submits from
  anywhere. Digits only. A box that GREW into being full hands focus to the next
  one and selects it — growth only, so deleting the last digit or retyping
  inside a full box never throws focus forward. Backspace in an empty box hands
  focus back with the caret at the end. ArrowLeft at caret 0 and ArrowRight at
  the end cross the boundary; both preventDefault only if a neighbour existed.
- Paste is date-aware: split the pasted text on non-digits, and if exactly three
  numeric groups come out, fill all three boxes. A four-digit group is the year
  wherever it sat, so "1996-05-04" pasted into a day-first gate is still 1996;
  a LEADING year also settles the other two, because a year-first string is ISO
  and therefore month then day — reading them in the gate's own order would
  silently swap 4 May for 5 April, which is the exact class of quiet correction
  this component refuses. With the year trailing or absent the source order is
  unknowable, so the gate's own visual order is used and the boxes show the
  result for the visitor to check. Any other shape falls through to the browser
  so a normal single-box paste still works.
- Announcement is a focus move, not a live region: there is exactly one thing to
  say, and moving focus says it while also putting the keyboard where the fix
  belongs. On a field-level refusal focus lands on the offending box (or the
  first box when the whole answer is at fault) and the screen reader reads
  label + invalid + message. On a terminal decision the form unmounts, so focus
  goes deliberately to the panel heading (tabIndex={-1}) — never to <body>.
  Queue that move in a ref that the effect consumes, so a gate that mounts
  already decided never steals focus from whatever the visitor was doing.
- The submit button is NEVER disabled. An inert button with no explanation is
  the oldest dark pattern in the form; pressing it with empty boxes says what is
  missing instead. Nothing else here is disabled either, so no control can blur
  a focused element to <body>.
- The refusal panel leads with the way out: exitHref rendered as a real anchor
  (Button asChild), or a button when onExit is given for router-driven exits. A
  refusal with no exit is a trap, not a gate. The secondary "I typed the wrong
  date" returns to the boxes with the date intact — fixing a typo, not being
  asked to guess again. allowCorrection={false} removes it, and the doc comment
  says plainly that this does not make the gate stronger (a reload clears
  everything), it only makes an honest mistake unfixable.
- Copy never scolds, never jokes, never implies a second guess would work, and
  never reports the computed age back at the visitor. The refusal states the
  rule once, says nothing was sent anywhere, and stops.
- This component stores NOTHING: no localStorage, no cookie, no fetch. The
  checkbox is consent, and it rides along in the onVerified payload for the host
  to honour. Consequently there are no timers, listeners, observers or subs to
  cancel; the only imperative work is that one queued focus move.

Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
  rounded-xl panel, text-muted-foreground for secondary copy, text-destructive
  for the refusal message and the shield, text-primary for the confirmation,
  accent-primary on the checkbox, ring-ring focus rings via the primitives.
- One column, max-w-md, centred by the root section; the three boxes are a flex
  row where the year gets flex-[1.6] so a four-digit box never squeezes the
  other two. Boxes are text-center tabular-nums. Long copy and long brand names
  get wrap-anywhere.
- Reduced motion: the panels use motion-safe:animate-in motion-safe:fade-in-0,
  so under prefers-reduced-motion they simply appear. Nothing functional depends
  on the animation.

Customization levers
- The rule: minimumAge (18) and maximumAge (120) are the whole policy. 21 for US
  alcohol, 16 for some jurisdictions; maximumAge is what makes a mistyped 1096
  read as a typo rather than a 930-year-old.
- Layout of the answer: fieldOrder "auto" follows the locale; pin it to "dmy" /
  "mdy" / "ymd" when the site's audience is not the browser's locale. autoAdvance
  false gives a strictly manual Tab flow.
- Sub-blocks: showRemember={false} drops the consent line, allowCorrection=
  {false} drops the retry, mark adds a brand lockup above the heading. Pass
  onExit for a router exit or exitHref for a plain link.
- Every string: the copy map is the whole i18n surface — 20 strings plus one per
  refusal code, all with {token} slots. Ship a translated map, not a fork.
- Palette and shape: the panel is one bg-card / border / rounded-xl box, so
  dropping it onto a full-screen backdrop (fixed inset-0 grid place-items-center
  with a blurred or branded background) is a wrapper, not a change to this file.
- Escalation: to gate content rather than a page, render this instead of the
  content and swap in the children on the onVerified callback; to persist the
  decision, write your own record in onVerified (with remember deciding session
  vs durable storage) and skip rendering the gate when you have one. Keeping the
  storage decision outside this component is what lets one gate serve a cookie
  banner, a signed-in profile and a server-side session cookie without changes.

Concepts

  • Injected clock, fail closednow is a prop, so the same draft always yields the same verdict and the panel renders identically on the server. When the instant will not parse there is no clock, therefore no verdict, therefore no entry: the one thing a gate must never do is open because it could not tell the time.
  • Derived verdict — the component stores the draft and one phase flag; who is old enough is recomputed by a pure function on every render. That is why no prop combination can put a confirmation panel above a failing date, and why the same function can be re-run on the server to check the client did not lie about its own arithmetic.
  • Nothing is guessed — 30 February is refused by a leap-year-aware table rather than corrected into 1 March by a Date constructor, and a two-digit year is refused rather than expanded into a century. Every silent correction is a door left open.
  • Segmented, not a masked field — three boxes with their own labels and bday-* autofill tokens, joined by grow-only auto-advance, edge-crossing arrows, Backspace-back and a date-aware paste. They are one answer, so they share one message and one fieldset.
  • Honest refusal — the refusal states the rule once, does not taunt, does not report the age back, leads with a real way out and keeps a route back for a genuine typo. Focus moves to the panel heading, so the outcome is announced by the same act that puts the keyboard somewhere useful.
  • Legal theatre, named as such — a self-declared birthday stops the careless, not the determined. This block's job is to ask the question properly, refuse impossible answers, and hand a clean verdict to whatever actually enforces the rule.

On This Page