Feature Flag Rollout
The rollout control for one flag — a hashed percentage gate with its estimated reach, ordered first-match-wins targeting rules, and a live evaluation preview that names the deciding rule or bucket.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/feature-flag-rollout.jsonPrompt
Build a React + TypeScript + Tailwind "FeatureFlagRollout" panel (lucide-react
icons, zod) that edits the exposure of ONE feature flag.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
flag: { key, enabled, rolloutPct (integer 0-100), rolloutServe,
defaultServe } | null;
rules: { id, attribute, operator, value, serve }[];
audience: { totalUsers } | null;
observed?: { servedPct, window? };
preview?: { unitKey?, attributes?: Record<string,string> } }.
- operator is a closed enum: equals | not_equals | in | not_in | contains |
gte | lte | semver_gte | semver_lt. Closed because every operator must be
evaluable — a payload must not be able to put an operator on screen that the
preview silently skips.
- flag and audience are null for loading and error: no flag arrived, so no
header is drawn. `empty` means the flag exists with zero rules.
- Props = z.infer of the schema plus onRolloutPctChange?, onRetry?, locale
(default "en-US"), label, and the rest spread on the root; forwardRef.
The preview's own field values are internal state seeded from `preview` the
first time one arrives — not in a useState initializer, because the panel is
mounted on `loading` with no payload and gets the seed on a later render of
the same instance — and owned by the component from then on, so a refresh
never overwrites fields someone is typing into.
Behavior
- Evaluation order, implemented once as a pure exported function so the rule
list, the preview and the result panel can never disagree:
1. flag.enabled === false -> serves defaultServe; no rule is evaluated and
the gate is idle (this model has no separate off variation).
2. rules top to bottom, FIRST MATCH WINS -> that rule's serve. A matched rule
bypasses the gate, which is what makes "always on for staff at 5%" work.
Rules below the winner are marked "not reached", never re-evaluated.
3. fall-through -> the percentage gate.
4. no unit key -> no bucket and NO serve claimed; say so instead of guessing.
- Bucketing is real, not Math.random: MurmurHash3 x86_32 over UTF-8 bytes
(Appleby's reference: 4-byte little-endian body, fmix32 avalanche, Math.imul
for 32-bit multiplies), normalised the way Unleash does —
bucket = murmur3("<flagKey>:<unitKey>") % 100 + 1, in the gate when
bucket <= rolloutPct. Salting with the flag key keeps two 20% flags from
hitting the same fifth of the audience; buckets start at 1, so 0% reaches
nobody and 100% reaches everybody with no special case. Keep rolloutPct an
integer: on a 100-bucket grid 12.5% does not exist.
- Operators are implemented properly: in/not_in read value as a comma list,
gte/lte parse both sides as decimal numbers (non-numeric never matches),
semver_* follow SemVer 2.0.0 §11 precedence with build metadata ignored (§10)
and refuse to compare an unparseable version. An attribute the context does
not carry never matches — not even a negated operator, otherwise one missing
attribute hands the feature to everyone.
- Derived figures are computed in-component from the same numbers: headcounts
come from largest-remainder (Hamilton) apportionment of totalUsers, so the
inside/outside counts always add up to the audience exactly; drift is
observed.servedPct minus the intent (0 while the flag is off, not the staged
percentage), in points and in users. Nothing is asserted that the payload
does not support: with rules present the panel says the headcount covers the
gate alone, and with the flag off it withholds headcounts entirely. The
explanation of a surplus follows the kill switch too — rule matches served on
top of the gate only while the flag is on; with it off the gate is idle, so
the surplus is named as measurement predating the kill switch or SDKs still
on a cached config.
- The preview asks only for attributes the rules actually read, in first
appearance order, plus the unit key. It re-evaluates on every keystroke,
highlights the deciding row, and announces the outcome through role=status.
Rendering & styling
- Semantic tokens only: bg-card panel, border-b section rules, bg-primary for
the gate fill and the matched badge, var(--chart-2) for the observed bar,
bg-muted tracks, text-muted-foreground for supporting copy, text-destructive
for the error icon. Figures are tabular-nums, keys and values font-mono.
- Reuse the shadcn primitives (Badge, Button, Input) rather than restyling
inputs; the gate uses a native range with accent-primary and aria-valuetext
carrying the reach sentence.
- Rows that recede — a rule marked "not reached", the idle gate row of an off
flag — change surface and text token (bg-muted/40 + text-muted-foreground),
never opacity: dimming muted copy to 60% drops it to 2.35:1 light and 3.37:1
dark, and those rows still carry the operator, the serve and the outcome.
- cn() merges className; bar widths transition with motion-reduce:transition-none
and the skeleton stops pulsing under motion-reduce:animate-none.
- Any text following an expression uses an explicit {" "} (or is built in JS),
because JSX drops the newline next to an expression container.
Customization levers
- Gate editability: pass onRolloutPctChange for a slider, omit it for a
read-only meter — never render a slider that saves nothing.
- Operator set: add an operator by extending the enum, OPERATOR_LABEL and the
switch in matchesRule together; the three must stay in lockstep.
- Bucket grid: GATE_BUCKETS is one constant — raise it to 1000 (and widen
rolloutPct to one decimal) if you need finer steps than a whole percent.
- Sub-blocks: the observed panel disappears with `observed`, the rule list
collapses to the gate row on an empty flag, and the preview can be dropped
wholesale for a read-only audit view.
- Density: the panel is four bordered sections — drop the borders and tighten
p-4 to p-3 for a sidebar, or raise the gate figure from text-3xl for a hero.
- Palette: swap var(--chart-2) for any chart token to re-key observed-vs-intent,
and the serve badges follow whatever Badge variants your theme defines.Concepts
- First match wins — rules are an ordered program, not a set of filters: the winner decides the serve, everything below it is marked "not reached", and a match bypasses the percentage gate entirely. Reordering two rules changes what ships.
- Stable bucketing — the gate is a hash, not a dice roll:
murmur3(flagKey + ":" + unitKey) mod 100 + 1puts a unit in the same bucket on every evaluation and in a different bucket under every other flag, so raising the percentage only ever adds users and two experiments never overlap by accident. - Intent versus observed — the configured gate is what you asked for;
observed.servedPctis what the service reports it did. The panel prints the gap in points and in users and names the ordinary cause (rule matches are served on top of the gate) instead of calling every gap an incident. - Refusing to guess — no unit key means no bucket, so the preview reports "serve unknown" rather than defaulting to a side; an absent attribute matches nothing, not even a negated operator; and with the flag off the headcounts are withheld instead of being restated as reach.
- Derived once — headcounts come from largest-remainder apportionment of the audience and the drift is computed from the same rounded percentage, so the split, the reach and the drift can never contradict each other on screen.
Release Health
A per-release stability readout — adoption derived from session counts, crash-free session and user rates against the payload's own bars, the change from the previous release by semver precedence, and the top crash groups with what it would take to clear the bar.
Spinner
One indeterminate loader, seven shapes — three boxed (ring, dual-ring, bars) and four inline dot rows (dots, ellipsis, bounce, wave), colored by a single tone prop or plain currentColor.