Feature Flag List
A per-environment feature flag table — production toggles gated behind a confirmation that repeats the flag key, rollout percentage kept orthogonal to on/off, optimistic switches that roll back one at a time, and expandable targeting rules.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/feature-flag-list.jsonPrompt
Build a React + TypeScript + Tailwind "FeatureFlagList" component with zod and
lucide-react, reusing a switch component, a confirm-dialog component and a
keyed optimistic-update hook rather than rewriting them.
Contract
- A zod contract file is the single source of truth. `featureFlagSchema`:
{ key, description?, environments: Record<envId, { enabled: boolean,
rollout?: number 0..100 }>, targeting?: { summary, rules?: string[] },
updatedBy, updatedAt (ISO 8601 with offset) }. `featureFlagEnvironmentSchema`:
{ id, label, danger?: "none" | "confirm" | "type-key" }. The list schema is
{ status: "loading" | "empty" | "error" | "ready", environments[], items[] }.
- `danger` is DATA, not a hard-coded `id === "prod"` test — every org draws the
line somewhere else (some guard staging, some run four prod regions), so the
guard travels with the environment definition.
- `enabled` and `rollout` are orthogonal and must never be collapsed into one
number. An off flag is NOT "0%": { enabled: true, rollout: 0 } means "the
gate is open and nobody is through it yet", { enabled: false } means "there
is no gate", and omitting `rollout` means "plain on/off flag". `rollout` is
deliberately kept while the flag is off so re-enabling resumes there.
- A `superRefine` rejects duplicate flag keys, duplicate environment ids, and
any flag whose environment record does not cover exactly the declared columns
(a missing cell would render as a hole; an undeclared one is invisible data).
Guard every check separately: zod runs ALL checks even after one fails, so a
later check that dereferences what an earlier one rejected throws a TypeError
out of `safeParse` instead of returning { success: false }.
- Props: the contract data, plus now (ISO | epoch ms | Date), locale
(default "en-US"), timeZone (default "UTC"), onToggle?(change), onRetry?,
filterable? (default true), skeletonRows? (clamped 1-24), emptyState?,
label?, className. Render never calls Date.now() / new Date() /
Intl.*(undefined), so SSR and hydration are byte-identical.
- Affordances are handler-gated: no `onToggle` means no switches at all (a
read-only dot + state text instead of a row of dead controls); no `onRetry`
means the error branch has no button.
Behavior
- Four first-class branches on `status`. `ready` with zero items resolves to
`empty`. loading draws the SAME table geometry as ready (a skeleton with its
own layout lines up on one viewport and jumps on every other) plus one
sr-only role="status".
- Toggling: `danger: "none"` flips on click. `"confirm"` opens a confirmation
whose body REPEATS THE FLAG KEY verbatim plus the environment label — the
reader is one click from changing what real users get, and the copy has to
make "which flag" unmissable. `"type-key"` additionally requires the key to
be retyped before the confirm button unlocks. Cancel / Escape changes
nothing at all — not the value, not the last-modified column.
- The write is optimistic: the new value paints immediately, the switch spins,
and the confirmation closes at once instead of holding the reader in a modal.
On rejection ONLY that one switch reverts and it keeps the error message in
its own cell — per-key overlays, never one shared "saving" flag. A second
click on a pending switch is refused. A synchronous throw counts as a
rejection. Announce both the attempt and the rollback in one polite live
region mounted for the whole component.
- The confirmation is controlled and has no trigger element, so Radix focuses
nothing when it closes and focus falls to `body`. Capture the switch that
opened it and restore focus in a deferred macrotask (Radix's own no-op
restore runs first).
- Filtering is client-side and real: a search box matching key + description +
targeting summary + rules, and a select of "On in <env>" / "Off in <env>".
The filter reads the SERVER state, not the optimistic overlay, so a row you
just toggled does not jump out from under the pointer mid-write. Filtering to
zero rows shows a DIFFERENT empty state from "you have no flags", names what
was filtered, and offers "Clear filters".
- Targeting: the summary is always rendered whole — no truncate, no line
clamp, no max-height — because "North America + paid plans + client >= 2.1"
does not survive an ellipsis. The full clause list expands into a second
table row via a real aria-expanded / aria-controls pair, and no expander
renders when there are no extra rules.
- Time: `updatedAt` minus the injected `now` through Intl.RelativeTimeFormat,
with the absolute instant in `title` including the zone name, so the reader
is never guessing whose clock they are reading.
Rendering & styling
- A real `table`: `th scope="col"` per environment, `th scope="row"` per flag,
an sr-only `caption` (sr-only belongs on the caption box, never on the table
element — `width:1px` is only a lower bound for a table box and blows the
page out sideways). Every switch's accessible name is
"<flag key> in <environment label>"; a guarded column carries a shield icon
plus sr-only "(changes need confirmation)".
- `table-fixed` + a `colgroup`: environment and timestamp columns get fixed
widths and the flag column absorbs the rest. Under `table-layout: auto` a
long targeting summary wins the width fight and pushes the switches off
screen. The table sits in a `w-full overflow-auto` card so a narrow viewport
scrolls the grid instead of the page. Long keys use `wrap-anywhere`
(`break-words` does not lower min-content, so one long key still forces the
column wider than its share; measured at 375px, a snake_case key without
`wrap-anywhere` paints 381px of ink straight across the switch columns).
- Two things on that scroll container are load-bearing and look like noise:
`relative`, because `sr-only` is `position: absolute` and a *static* overflow
parent is not its containing block — measured at 375px, the sr-only
"(changes need confirmation)" span escaped the card, landed at x=486 and gave
the page 112px of invisible horizontal scroll; and `tabIndex={0}` +
`role="group"` + `aria-label`, because a grid only a mouse can pan is a WCAG
2.1.1 failure, and Chromium's built-in focusable-scroller behaviour skips any
scroller that has focusable descendants (this one has the expanders, which all
sit in the first column and never scroll it sideways). `group`, not `region`:
a landmark per instance would pollute screen-reader landmark navigation.
- Semantic tokens only: bg-card / border (the table shell), bg-muted +
text-muted-foreground (skeletons, secondary text, meter track), bg-primary
(meter fill, on-state dot), text-destructive (error branch and per-cell
failures), focus-visible:ring-2 ring-ring on every control. cn() merges the
consumer className into the root.
- On/off is never colour alone: it is the switch's knob position, its
aria-checked, and the word "On" / "Off". The rollout meter is redundant with
the printed percentage, so a monochrome or zoomed-in reader loses nothing.
- Only the pulse, the meter width and the chevron rotation animate, all with
motion-reduce:*-none; nothing stops working with motion off.
Customization levers
- Guard policy: `danger` per environment is the dial. Guard staging too, run
several production regions, or set every column to "type-key" for a
compliance-heavy product — no component change. The confirm button is styled
destructive for a turn-off (pulling a feature out from under users already on
it) and neutral for a turn-on; flip that if your product treats shipping as
the scarier direction.
- Environment count: columns come from the `environments` array. Two columns
or six; the fixed column width (104px) and the table's min-width follow.
- Rollout display: swap the meter for a numeric-only cell, or bucket the
percentage into named stages ("canary / partial / full") — keep the three
distinct readouts for off / on-at-0 / on-at-N, that distinction is the point.
- Extra columns: owner, flag type (release vs experiment vs kill switch), stale
age, linked ticket. Add them to the schema, the colgroup and the min-width
together.
- Filtering: with server-side filtering pass filterable={false} and hand in the
already-filtered `items`. To search extra fields, extend the haystack.
- Density: px-3 py-3 rows read as comfortable; py-2 with size="sm" switches
gives a much tighter console without touching the state machine.Concepts
- Danger as data — how scary an environment is lives on the environment record (
none/confirm/type-key), not in anid === "prod"branch. That is what lets one component serve a two-environment side project and a four-region regulated platform without a fork. - Key-repeating confirmation — the dialog does not ask "are you sure?", it prints the flag key you are about to change in production. The failure mode being defended against is not hesitation, it is hitting the switch on the wrong row, and only the key can catch that.
- Rollout is orthogonal to on/off — off is the absence of a gate; on-at-0% is an open gate nobody has walked through; on-at-100% is everyone. Rendering "off" as "0%" merges two operationally different situations into one, so the component keeps three distinct readouts and only paints a meter while the flag is on.
- Single-switch rollback — every cell is its own optimistic key, so a rejected write reverts exactly one switch and parks the reason in that cell. A shared "saving" flag would either lock the whole grid or silently revert changes the reader never made.
- Filter on server truth — the filter reads the props value, not the optimistic overlay, so the row you just toggled stays put while its write is in flight and only leaves once the change is real.
- Filtered-empty is not empty — "your filters hide everything" gets its own copy, names what it filtered on, and offers a way out; "you have no flags at all" explains where flags come from. Collapsing them into one screen tells a user with 200 flags they have none.
- Injected now — the "now" relative labels are measured against is a prop, so the component is a pure function of its inputs: no wall-clock read during render, identical SSR and hydration output, reproducible screenshots. The absolute tooltip names its timezone instead of leaving it implied.
Scrollbar Styles
Six presets that re-skin the browser's own scrollbar in pure CSS — no JavaScript, no drawn thumb, and one capability-tiered sheet that keeps the webkit pseudo-elements and the standard properties from cancelling each other out.
Webhook Log
A webhook delivery log with the retry lifecycle built in — outcome classes that keep timeouts separate from status codes, a countdown to the next attempt derived from an injected clock, masked credential headers, collapsed payloads with honest truncation, and a guarded replay confirmation.