Data Display

Feature Flag Row

A feature-flag control with an on/off switch and a rollout-percentage slider; the affected-users count rolls on an odometer as the percentage changes.

Install

npx shadcn@latest add @paragon/feature-flag-row

Also installs: switch, slider, digit-roll

feature-flag-row.tsx

"use client";

import * as React from "react";
import { Switch } from "@/registry/paragon/ui/switch";
import { Slider } from "@/registry/paragon/ui/slider";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
import { cn } from "@/lib/utils";

export interface FeatureFlagRowProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  /** Flag key, shown in monospace. */
  flagKey?: string;
  /** Human description. */
  description?: string;
  /** Total audience the rollout percentage applies to. */
  audience?: number;
  /** Uncontrolled initial enabled state. */
  defaultEnabled?: boolean;
  /** Uncontrolled initial rollout percentage (0–100). */
  defaultRollout?: number;
  onChange?: (state: { enabled: boolean; rollout: number }) => void;
}

/**
 * A feature-flag control: an on/off switch plus a rollout-percentage slider.
 * The affected-users count is derived from percentage × audience and rolls on
 * an odometer as you drag, so the blast radius of the change is always legible.
 * Disabling the flag dims the rollout controls and drops the effective count to
 * zero (rolling down). The slider stays keyboard-driven; the roll respects
 * reduced motion via DigitRoll.
 */
export function FeatureFlagRow({
  flagKey = "checkout.new_flow",
  description = "New multi-step checkout with saved cards",
  audience = 128_400,
  defaultEnabled = true,
  defaultRollout = 25,
  onChange,
  className,
  ...props
}: FeatureFlagRowProps) {
  const [enabled, setEnabled] = React.useState(defaultEnabled);
  const [rollout, setRollout] = React.useState(defaultRollout);

  const affected = enabled ? Math.round((rollout / 100) * audience) : 0;

  const emit = (next: { enabled: boolean; rollout: number }) =>
    onChange?.(next);

  return (
    <div
      data-slot="feature-flag-row"
      className={cn(
        "rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-start gap-3">
        <div className="min-w-0 flex-1">
          <code className="text-[13px] font-medium">{flagKey}</code>
          <p className="mt-0.5 truncate text-xs text-muted-foreground">
            {description}
          </p>
        </div>
        <Switch
          checked={enabled}
          onCheckedChange={(v) => {
            setEnabled(v);
            emit({ enabled: v, rollout });
          }}
          aria-label={`Toggle ${flagKey}`}
        />
      </div>

      <div
        className={cn(
          "mt-4 transition-opacity duration-(--duration-base)",
          enabled ? "opacity-100" : "opacity-50",
        )}
      >
        <div className="flex items-center justify-between text-xs">
          <span className="text-muted-foreground">Rollout</span>
          <span className="font-medium tabular-nums">{rollout}%</span>
        </div>
        <Slider
          className="mt-2"
          value={[rollout]}
          min={0}
          max={100}
          step={1}
          disabled={!enabled}
          ticks={[0, 25, 50, 75, 100]}
          aria-label="Rollout percentage"
          formatValue={(v) => `${v}%`}
          onValueChange={([v]) => {
            setRollout(v);
            emit({ enabled, rollout: v });
          }}
        />
        <div className="mt-3 flex items-baseline gap-1.5 text-xs text-muted-foreground">
          <DigitRoll
            value={affected}
            className="text-sm font-semibold text-foreground"
          />
          <span>
            of <span className="tabular-nums">{audience.toLocaleString()}</span>{" "}
            users affected
          </span>
        </div>
      </div>
    </div>
  );
}