Mobile

Criteria Checklist

A live rule-satisfaction panel for a field being typed on a phone: a row per rule, a two-column grid, or a one-line summary of the next thing to fix — chosen from the keyboard height the visual viewport actually reports.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronDown, Circle, LoaderCircle, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** What one rule is currently saying about the value. */
export type CriterionState = "idle" | "unmet" | "met" | "checking" | "failed"

/** How much of the checklist is drawn. Three shapes, plus one that picks for you. */
export type CriteriaChecklistVariant = "list" | "grid" | "compact" | "auto"

/** Past this much occlusion the software keyboard is considered up. */
const KEYBOARD_UP_PX = 96

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/criteria-checklist.json

Prompt

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

Build a React + TypeScript + Tailwind "CriteriaChecklist" component: the live
rule-satisfaction panel that sits under a field on a phone and changes shape
when the software keyboard takes half the screen. React + lucide-react only, no
form or validation library.

Contract
- "use client". forwardRef<HTMLDivElement, CriteriaChecklistProps> extending
  React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root.
- export type CriterionState = "idle" | "unmet" | "met" | "checking" | "failed"
- export interface Criterion { id; label; test?: (value: string) => boolean;
  state?: CriterionState; hint?: string; optional?: boolean }
  - `test` is a pure predicate re-run on every keystroke.
  - `state` is a host-owned verdict (a server round trip, a zod issue, a breach
    list). It WINS over `test`, and it is the only way to reach "checking" or
    "failed" — the browser cannot discover either on its own.
  - `optional` is drawn as a row but sits outside the count, the meter and the
    satisfied verdict.
  - Duplicate ids are dropped; the id is the React key and the meter segment.
- Props: criteria (required), value = "", variant: "list" | "grid" | "compact" |
  "auto" = "list", open / defaultOpen = false / onOpenChange (compact only),
  onSatisfiedChange?, title = "Requirements", showSummary = true,
  announce = true, announceDelay = 500 (clamped 0..5000), keyboardInset?,
  edge = false, labels?: Partial<CriteriaChecklistLabels>.
- The component never owns the input. `value` is the field's current value,
  passed in; there is no ref-hunting and no uncontrolled text state. The one
  piece of state worth owning is the compact expansion, and that is controlled
  (`open`) and uncontrolled (`defaultOpen`) both.
- Mirror the resolved layout onto the root as data-variant, and the keyboard
  verdict as data-keyboard="up" | "down" (only when something is watching it).

Behavior
- State resolution per rule, in this order: an explicit `state` wins; with no
  `test`, "idle"; with an EMPTY value, "idle"; otherwise test(value) ? "met" :
  "unmet". The empty-value rule is the important one — a fresh field must not be
  a wall of red crosses, and a rule that passes vacuously on "" (say "no spaces
  at either end") must not claim to be met before anything was typed.
- Counting: only non-optional rules count. satisfied = total > 0 AND every
  required rule is "met". An EMPTY rule set never reports satisfied — a list
  that failed to load must not unlock a submit button.
- onSatisfiedChange fires once on mount with the starting verdict, then only on
  flips, compared against a ref (never against state) and read through a ref so
  an inline arrow function does not re-fire it.
- variant="auto" is the mobile core: keyboard height =
  window.innerHeight - visualViewport.height - visualViewport.offsetTop, clamped
  at 0, rounded, subscribed through useSyncExternalStore over BOTH resize and
  scroll (offsetTop is what keeps it honest when iOS scrolls the page under a
  raised keyboard; on an Android window that resizes instead, the formula lands
  on 0, which is also correct). Server snapshot 0. >= 96px means the keys are up
  and the layout becomes "compact"; below that it is "list". Subscribe ONLY when
  the answer is used (variant="auto" or edge, and no keyboardInset given) —
  a static list has no business listening to the viewport. Never ship a
  device-height table.
- Compact is not a smaller list, it is a different message: the title, the count,
  and ONE line — the first unmet rule ("Next · a number"), or the refused rule if
  there is one. The whole summary row is the disclosure button (min-h-11), so the
  full set is one thumb tap away; the rows themselves are never interactive,
  because they are derived state, not checkboxes.
- The list is NEVER a live region. One polite role="status" sr-only line
  announces "3 left: …" (at most three names, then "+n"), the refusal, or "All
  requirements met", debounced by announceDelay and cleared after ~2.4s so an
  identical next result is read again. It is seeded with the mount verdict, so
  it never announces a state the user did not cause. Announcing per keystroke, or
  putting aria-live on the list, is a screen-reader storm.
- Collapsing hides the body with the `hidden` attribute on a wrapper that has NO
  display utility (a `grid`/`flex` class on the same element would beat it), so
  aria-controls always resolves and nothing is unmounted under a focused node.
- Cleanup: the debounce timer, the clear timer and the viewport subscription all
  come off on unmount and whenever their dependency changes.

Rendering & styling
- Semantic tokens only: rounded-2xl border bg-card for the panel, text-foreground
  for a rule still to do, text-muted-foreground once it is met, and
  bg-foreground + text-background for the met tick — the highest-priority mark
  INVERTS rather than taking a colour. Only a genuine refusal is destructive
  (bg-destructive + text-background, because this theme has no
  --destructive-foreground). Merge the consumer's className last with cn().
- Ink tracks the work LEFT, not the work done: unmet labels are the darkest
  thing in the panel, met labels step back to grey behind their tick, idle rules
  are lighter still.
- Type scale small and tight: 13px title and rows, 11px count and hints, 10px
  optional badge; the count is tabular-nums so it does not jitter while typing.
- Meter: one 12px segment per required rule (met = bg-foreground, refused =
  bg-destructive, else bg-muted); past eight rules it collapses into a single
  continuous bar, because nine segments are unreadable at 390px. aria-hidden —
  the "3/5 met" line already says it.
- Layouts: list = a row per rule, labels WRAP (a requirement you cannot read is
  useless) and may carry a hint line; grid = auto-fit minmax(140px, 1fr) chips,
  hints and badges dropped, which halves the height; compact = the summary line
  only, truncated on purpose, with the full text one tap away.
- Accessibility: role="group" + aria-labelledby pointing at the TITLE only (not
  the whole summary row, or the count is read twice); each row carries an sr-only
  state word (", met" / ", not met yet" / ", checking" / ", rejected") plus
  ", optional"; the disclosure button carries aria-expanded + aria-controls and
  an sr-only "Show all requirements"; every decorative glyph is aria-hidden.
- Touch: the only control is the summary row, min-h-11 (44px) and full width.
  Nothing depends on hover, and there is no gesture to have a fallback for.
- prefers-reduced-motion: the chevron rotation, the bar width transition and the
  "checking" spinner all drop their animation (motion-reduce:*); the gapped
  loader ring still reads as in-flight standing still, and the sr-only word
  carries the rest.
- Safe area: `edge` marks the panel as sitting on the bottom edge of the screen —
  pad with max(0.75rem, env(safe-area-inset-bottom)) while the keyboard is DOWN,
  and drop it while the keyboard is UP, because the keys already cover that
  strip. Safe area is a state, not a constant.

Customization levers
- variant is the density axis: "list" when the field is at the top of a quiet
  screen, "grid" when five or more rules must survive above the keys, "compact"
  inside a bottom sheet, "auto" to let the keyboard decide.
- keyboardInset is the injection seam: a native shell (Capacitor, a WebView)
  already knows the keyboard height, and a preview or a test has no keyboard to
  measure. Passing it skips the subscription entirely.
- Raise or lower the 96px keyboard threshold if your app has a persistent
  accessory bar, and the 500ms announceDelay for a slower typist.
- labels is the whole i18n seam — the state words, "left", "Next", the two
  disclosure strings and the empty line. There is no other prose.
- showSummary=false leaves the rules alone and drops the count and meter (a
  three-rule panel rarely needs a score); announce=false hands the live region
  back to a form-level announcer you already have.
- Wire the field to the panel with aria-describedby when the rule set is short;
  for a long one, leave the panel out of the description and let the coalesced
  live region do the talking.
- To add a state (say "warning"), extend CriterionState, add a glyph branch and a
  label word — the counting only asks whether a state is "met", so nothing else
  has to change.

Concepts

  • Derived, never toggled — every row is a read-only verdict about value, so nothing here is clickable. That is the line between this component and an onboarding checklist: there, ticking a box is the progress; here, a box the user could tick would be a lie about what the server will accept.
  • Pristine is not failure — an empty field resolves every derived rule to idle, not unmet. It keeps a fresh signup from opening with five crosses, and it stops a rule that passes vacuously on the empty string (“no space at either end”) from claiming to be met before anything was typed.
  • The keyboard is measured, not guessedinnerHeight - visualViewport.height - offsetTop, subscribed to both resize and scroll, and only subscribed at all when variant="auto" or edge actually needs the answer. offsetTop is the part everyone forgets: iOS scrolls the page under a raised keyboard. A device-height lookup table is wrong on the first phone you did not test.
  • Compact is one instruction, not a summary of six — with two lines of room above the keys, listing everything is noise; the panel shows the count and the single next thing to fix, and the whole 44px summary row is the button that reveals the rest. Truncation there is deliberate, because the full sentence wraps in the expanded grid.
  • Coalesced, not live — putting aria-live on the list means re-reading every rule on every keystroke. Instead one polite role="status" line speaks after the states hold still, names at most three outstanding rules, and clears itself so the next identical verdict is announced again rather than swallowed.
  • Safe area is a state — with the keyboard down, a panel on the bottom edge pads itself by env(safe-area-inset-bottom) so the home indicator does not sit on the last rule; with the keyboard up that strip is already covered and the padding goes away.

On This Page