Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowDownRight, ArrowUpRight, Equal, Flag, Percent, TriangleAlert } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type {
  FeatureFlagRolloutData,
  FeatureFlagRolloutFlag,
  FeatureFlagRolloutRule,
  RolloutOperator,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/feature-flag-rollout.json

Prompt

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 + 1 puts 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.servedPct is 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.

On This Page