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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/criteria-checklist.jsonPrompt
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, notunmet. 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 guessed —
innerHeight - visualViewport.height - offsetTop, subscribed to bothresizeandscroll, and only subscribed at all whenvariant="auto"oredgeactually needs the answer.offsetTopis 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-liveon the list means re-reading every rule on every keystroke. Instead one politerole="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.
Answer Highlight
The conclusion of an AI answer stated first — four monochrome priority levels across plain, rule, KPI and icon compositions, plus a rail that pins the verdict below the notch once the reader has thumbed past the card.
Reasoning Timeline
A thumb-scrubbed reasoning chain for phones — the running node is called out on an inverted card, resolved nodes fold into a count, and the window never becomes a nested scroller.