Feature flags
Feature flag screens. Switches per environment, a staged rollout with a kill switch, targeting rules with a test person, an A/B split with results and a comparison across environments.
One of 3 components in Developer settings
Variants
Details
What Feature flags brings along, read from the registry and the source.
- Category
- Developer settings
- Area
- App
- Variants
- 5
- Version
- 1.0.1 · since 1.1.0
- File
- components/ui/feature-flags.tsx
- npm packages
- None
- Languages
- English and German, 86 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FeatureFlagsProps interface from components/ui/feature-flags.tsx, with the comments from the code.
variantoptionalFeatureFlagsVariantflag-listrollouttargetingexperimentenvironments
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringtodayoptionalstringISO date that counts as today
environmentsoptionalFlagEnv[]flagsoptionalFlag[]flagoptionalFlagrollout, targeting, experiment, environments: the flag on screen
stagesoptionalnumber[]rollout: steps of a staged rollout in percent
rulesoptionalTargetRule[]targeting
attributesoptional{ id: string; label: string; placeholder?: string }[]variantsoptionalstring[]targeting, environments: names of the variants a flag can serve
armsoptionalExperimentArm[]experiment
metricoptionalstringconfigsoptionalEnvConfig[]environments
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on every saved change. Without it the block simulates the request.
Built-in texts
Feature flags writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 86 texts
| Key | English | German |
|---|---|---|
| search | English Search flags | German Flags suchen |
| discard | English Discard | German Verwerfen |
| all | English All | German Alle |
| stale | English Stale | German Veraltet |
| staleHint | English Unchanged for over 60 days. Remove it from the code when you can. | German Seit über 60 Tagen unverändert. Entferne es aus dem Code, sobald es geht. |
| flag | English Flag | German Flag |
| owner | English Owner | German Verantwortlich |
| updated | English Changed {date} | German Geändert {date} |
| toggle | English {flag} in {env} | German {flag} in {env} |
| turnedOn | English {flag} is on in {env}. | German {flag} ist in {env} an. |
| turnedOff | English {flag} is off in {env}. | German {flag} ist in {env} aus. |
| rolloutN | English {n}% rollout | German {n} % ausgerollt |
| noMatch | English No flag matches your search. | German Kein Flag passt zur Suche. |
| clear | English Clear search | German Suche leeren |
| copyKey | English Copy key {key} | German Schlüssel {key} kopieren |
| copied | English Copied | German Kopiert |
| rollout | English Rollout | German Ausrollen |
| rolloutText | English Share of signed in users in production who get the feature. The same user always lands on the same side. | German Anteil der angemeldeten Nutzer in Produktion, die die Funktion bekommen. Dieselbe Person landet immer auf derselben Seite. |
| users | English {n} of 100 users | German {n} von 100 Nutzern |
| stagesTitle | English Staged rollout | German Stufenweise ausrollen |
| stagesText | English Raise the share step by step and watch the error rate in between. | German Erhöhe den Anteil Schritt für Schritt und beobachte dazwischen die Fehlerquote. |
| stage | English Step {n} | German Stufe {n} |
| current | English Now | German Jetzt |
| nextStep | English Go to {n}% | German Auf {n} % gehen |
| full | English Released to everyone | German Für alle freigegeben |
| kill | English Turn off for everyone | German Für alle ausschalten |
| killText | English Stops the feature in production right away, the rollout stays saved. | German Stoppt die Funktion in Produktion sofort, der Ausrollstand bleibt gespeichert. |
| killed | English Off for everyone. Turn it on again to continue at {n}%. | German Für alle aus. Schalte es wieder ein, um bei {n} % weiterzumachen. |
| resume | English Turn on again | German Wieder einschalten |
| save | English Save rollout | German Ausrollen speichern |
| saving | English Saving… | German Wird gespeichert… |
| saved | English Rollout saved at {n}%. | German Bei {n} % gespeichert. |
| rules | English Targeting rules | German Zielregeln |
| rulesText | English Rules are checked from top to bottom. The first match decides. | German Regeln werden von oben nach unten geprüft. Der erste Treffer entscheidet. |
| if | English If | German Wenn |
| serve | English then serve | German dann |
| everyoneElse | English Everyone else gets | German Alle anderen bekommen |
| addRule | English Add rule | German Regel hinzufügen |
| removeRule | English Remove rule {n} | German Regel {n} entfernen |
| moveUp | English Move rule {n} up | German Regel {n} nach oben |
| moveDown | English Move rule {n} down | German Regel {n} nach unten |
| attribute | English Attribute | German Merkmal |
| operator | English Operator | German Bedingung |
| value | English Value | German Wert |
| is | English is | German ist |
| is-not | English is not | German ist nicht |
| contains | English contains | German enthält |
| ends-with | English ends with | German endet auf |
| in | English is one of | German ist eins von |
| variant | English Variant | German Variante |
| test | English Try a user | German Nutzer ausprobieren |
| testText | English Fill in attributes and see which rule matches. | German Merkmale eintragen und sehen, welche Regel greift. |
| matches | English Rule {n} matches, this user gets {v} | German Regel {n} greift, diese Person bekommt {v} |
| noRule | English No rule matches, this user gets {v} | German Keine Regel greift, diese Person bekommt {v} |
| emptyValue | English Fill in a value | German Wert eintragen |
| saveRules | English Save rules | German Regeln speichern |
| savedRules | English Rules saved. | German Regeln gespeichert. |
| experiment | English Experiment | German Experiment |
| split | English Traffic split | German Aufteilung |
| splitText | English Drag the handle or use the arrow keys. | German Griff ziehen oder Pfeiltasten nutzen. |
| visitors | English Visitors | German Besucher |
| conversions | English Conversions | German Abschlüsse |
| rate | English Rate | German Quote |
| lift | English vs. control | German ggü. Kontrolle |
| control | English Control | German Kontrolle |
| winner | English Likely better | German Wahrscheinlich besser |
| tooEarly | English Too early to call | German Noch zu früh |
| confidence | English {n}% sure | German {n} % sicher |
| applySplit | English Apply split | German Aufteilung übernehmen |
| splitSaved | English Split of {a}% and {b}% saved. | German Aufteilung von {a} % und {b} % gespeichert. |
| shareOf | English Share of {name} | German Anteil von {name} |
| envsText | English The same flag in each environment. Differences are marked. | German Dasselbe Flag in jeder Umgebung. Unterschiede sind markiert. |
| status | English Status | German Status |
| on | English On | German An |
| off | English Off | German Aus |
| rulesN | English {n} rules | German {n} Regeln |
| rulesOne | English 1 rule | German 1 Regel |
| unsavedRules | English Unsaved changes to the rules | German Ungespeicherte Änderungen an den Regeln |
| you | English you | German dir |
| fallback | English Everyone else | German Alle anderen |
| differs | English Differs from {env} | German Anders als {env} |
| promote | English Copy {from} to {to} | German {from} nach {to} kopieren |
| promoting | English Copying… | German Wird kopiert… |
| promoted | English {to} now matches {from}. | German {to} entspricht jetzt {from}. |
| same | English Same as {env} | German Wie {env} |
| by | English by {name} | German von {name} |
Accessibility
Read from the source of the component.
- Feature flags handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles radio, radiogroup, slider and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,235 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type FeatureFlagsVariant = 'flag-list' | 'rollout' | 'targeting' | 'experiment' | 'environments';
export interface FlagEnv {
id: string;
name: string;
}
export interface Flag {
id: string;
/** Key used in code, e.g. new-checkout */
key: string;
name: string;
description?: string;
/** On or off per environment id */
on: Record<string, boolean>;
/** Share of users that get it in production, 0 to 100 */
rollout?: number;
tags?: string[];
owner?: string;
/** ISO date of the last change */
updated?: string;
/** No change for a long time, safe to remove from the code */
stale?: boolean;
}
export interface TargetRule {
id: string;
attribute: string;
operator: 'is' | 'is-not' | 'contains' | 'ends-with' | 'in';
value: string;
/** Variant that the matching users get */
serve: string;
}
export interface ExperimentArm {
id: string;
name: string;
/** Share of traffic, all arms add up to 100 */
share: number;
visitors: number;
conversions: number;
control?: boolean;
}
export interface EnvConfig {
/** Environment id */
env: string;
on: boolean;
rollout: number;
rules: number;
/** Variant for everyone else */
fallback: string;
updated?: string;
by?: string;
}
export interface FeatureFlagsProps {
variant?: FeatureFlagsVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** ISO date that counts as today */
today?: string;
environments?: FlagEnv[];
flags?: Flag[];
/** rollout, targeting, experiment, environments: the flag on screen */
flag?: Flag;
/** rollout: steps of a staged rollout in percent */
stages?: number[];
/** targeting */
rules?: TargetRule[];
attributes?: { id: string; label: string; placeholder?: string }[];
/** targeting, environments: names of the variants a flag can serve */
variants?: string[];
/** experiment */
arms?: ExperimentArm[];
metric?: string;
/** environments */
configs?: EnvConfig[];
/** Called on every saved change. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
search: 'Search flags',
discard: 'Discard',
all: 'All',
stale: 'Stale',
staleHint: 'Unchanged for over 60 days. Remove it from the code when you can.',
flag: 'Flag',
owner: 'Owner',
updated: 'Changed {date}',
toggle: '{flag} in {env}',
turnedOn: '{flag} is on in {env}.',
turnedOff: '{flag} is off in {env}.',
rolloutN: '{n}% rollout',
noMatch: 'No flag matches your search.',
clear: 'Clear search',
copyKey: 'Copy key {key}',
copied: 'Copied',
rollout: 'Rollout',
rolloutText: 'Share of signed in users in production who get the feature. The same user always lands on the same side.',
users: '{n} of 100 users',
stagesTitle: 'Staged rollout',
stagesText: 'Raise the share step by step and watch the error rate in between.',
stage: 'Step {n}',
current: 'Now',
nextStep: 'Go to {n}%',
full: 'Released to everyone',
kill: 'Turn off for everyone',
killText: 'Stops the feature in production right away, the rollout stays saved.',
killed: 'Off for everyone. Turn it on again to continue at {n}%.',
resume: 'Turn on again',
save: 'Save rollout',
saving: 'Saving…',
saved: 'Rollout saved at {n}%.',
rules: 'Targeting rules',
rulesText: 'Rules are checked from top to bottom. The first match decides.',
if: 'If',
serve: 'then serve',
everyoneElse: 'Everyone else gets',
addRule: 'Add rule',
removeRule: 'Remove rule {n}',
moveUp: 'Move rule {n} up',
moveDown: 'Move rule {n} down',
attribute: 'Attribute',
operator: 'Operator',
value: 'Value',
is: 'is',
'is-not': 'is not',
contains: 'contains',
'ends-with': 'ends with',
in: 'is one of',
variant: 'Variant',
test: 'Try a user',
testText: 'Fill in attributes and see which rule matches.',
matches: 'Rule {n} matches, this user gets {v}',
noRule: 'No rule matches, this user gets {v}',
emptyValue: 'Fill in a value',
saveRules: 'Save rules',
savedRules: 'Rules saved.',
experiment: 'Experiment',
split: 'Traffic split',
splitText: 'Drag the handle or use the arrow keys.',
visitors: 'Visitors',
conversions: 'Conversions',
rate: 'Rate',
lift: 'vs. control',
control: 'Control',
winner: 'Likely better',
tooEarly: 'Too early to call',
confidence: '{n}% sure',
applySplit: 'Apply split',
splitSaved: 'Split of {a}% and {b}% saved.',
shareOf: 'Share of {name}',
envsText: 'The same flag in each environment. Differences are marked.',
status: 'Status',
on: 'On',
off: 'Off',
rulesN: '{n} rules',
rulesOne: '1 rule',
unsavedRules: 'Unsaved changes to the rules',
you: 'you',
fallback: 'Everyone else',
differs: 'Differs from {env}',
promote: 'Copy {from} to {to}',
promoting: 'Copying…',
promoted: '{to} now matches {from}.',
same: 'Same as {env}',
by: 'by {name}',
},
de: {
search: 'Flags suchen',
discard: 'Verwerfen',
all: 'Alle',
stale: 'Veraltet',
staleHint: 'Seit über 60 Tagen unverändert. Entferne es aus dem Code, sobald es geht.',
flag: 'Flag',
owner: 'Verantwortlich',
updated: 'Geändert {date}',
toggle: '{flag} in {env}',
turnedOn: '{flag} ist in {env} an.',
turnedOff: '{flag} ist in {env} aus.',
rolloutN: '{n} % ausgerollt',
noMatch: 'Kein Flag passt zur Suche.',
clear: 'Suche leeren',
copyKey: 'Schlüssel {key} kopieren',
copied: 'Kopiert',
rollout: 'Ausrollen',
rolloutText: 'Anteil der angemeldeten Nutzer in Produktion, die die Funktion bekommen. Dieselbe Person landet immer auf derselben Seite.',
users: '{n} von 100 Nutzern',
stagesTitle: 'Stufenweise ausrollen',
stagesText: 'Erhöhe den Anteil Schritt für Schritt und beobachte dazwischen die Fehlerquote.',
stage: 'Stufe {n}',
current: 'Jetzt',
nextStep: 'Auf {n} % gehen',
full: 'Für alle freigegeben',
kill: 'Für alle ausschalten',
killText: 'Stoppt die Funktion in Produktion sofort, der Ausrollstand bleibt gespeichert.',
killed: 'Für alle aus. Schalte es wieder ein, um bei {n} % weiterzumachen.',
resume: 'Wieder einschalten',
save: 'Ausrollen speichern',
saving: 'Wird gespeichert…',
saved: 'Bei {n} % gespeichert.',
rules: 'Zielregeln',
rulesText: 'Regeln werden von oben nach unten geprüft. Der erste Treffer entscheidet.',
if: 'Wenn',
serve: 'dann',
everyoneElse: 'Alle anderen bekommen',
addRule: 'Regel hinzufügen',
removeRule: 'Regel {n} entfernen',
moveUp: 'Regel {n} nach oben',
moveDown: 'Regel {n} nach unten',
attribute: 'Merkmal',
operator: 'Bedingung',
value: 'Wert',
is: 'ist',
'is-not': 'ist nicht',
contains: 'enthält',
'ends-with': 'endet auf',
in: 'ist eins von',
variant: 'Variante',
test: 'Nutzer ausprobieren',
testText: 'Merkmale eintragen und sehen, welche Regel greift.',
matches: 'Regel {n} greift, diese Person bekommt {v}',
noRule: 'Keine Regel greift, diese Person bekommt {v}',
emptyValue: 'Wert eintragen',
saveRules: 'Regeln speichern',
savedRules: 'Regeln gespeichert.',
experiment: 'Experiment',
split: 'Aufteilung',
splitText: 'Griff ziehen oder Pfeiltasten nutzen.',
visitors: 'Besucher',
conversions: 'Abschlüsse',
rate: 'Quote',
lift: 'ggü. Kontrolle',
control: 'Kontrolle',
winner: 'Wahrscheinlich besser',
tooEarly: 'Noch zu früh',
confidence: '{n} % sicher',
applySplit: 'Aufteilung übernehmen',
splitSaved: 'Aufteilung von {a} % und {b} % gespeichert.',
shareOf: 'Anteil von {name}',
envsText: 'Dasselbe Flag in jeder Umgebung. Unterschiede sind markiert.',
status: 'Status',
on: 'An',
off: 'Aus',
rulesN: '{n} Regeln',
rulesOne: '1 Regel',
unsavedRules: 'Ungespeicherte Änderungen an den Regeln',
you: 'dir',
fallback: 'Alle anderen',
differs: 'Anders als {env}',
promote: '{from} nach {to} kopieren',
promoting: 'Wird kopiert…',
promoted: '{to} entspricht jetzt {from}.',
same: 'Wie {env}',
by: 'von {name}',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion of the app screens */
/* ------------------------------------------------------------------ */
/*
* The same small set in every app block (auth, dashboard, files, inbox …),
* kept in the block so it ships on its own:
* useEnter entrance that waits until its area scrolls in. The area gets
* data-kitm-scope and data-kitm={state}, children take kitm-wipe
* (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
* kitm-up and kitm-pop, with --kitm-d as delay.
* useGlide a marker inside the active item ([data-glide]) glides over
* from where the previous one stood: tabs, segments, steps.
* kitm-in, kitm-fly, kitm-tick for things that appear after an action.
* The server renders the end state, without JavaScript nothing waits longer
* than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
* clip-path change, so no layout shifts.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
['grow-x', 'transform:scaleX(0)', 900],
['grow-y', 'transform:scaleY(0)', 800],
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
'@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
'.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
'@media (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
'[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
'}';
function KitmStyle() {
// React hoists it once into the head, however many app blocks render
return (
<style href="kitm" precedence="default">
{KITM_CSS}
</style>
);
}
/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
/**
* The marker ([data-glide]) is rendered inside the active item, so the server
* and a page without JavaScript show it in place. After a change it starts
* where the old one stood and glides into the new item.
*/
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
// Read during render, while the DOM still shows the old marker
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
const DAY = 86400000;
const toDay = (iso: string) => Math.round(Date.parse(iso + 'T00:00:00Z') / DAY);
type P = Omit<FeatureFlagsProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; today: string; locale: Locale };
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: FeatureFlagsProps['onAction'], action: string, data: Record<string, unknown>) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : 700);
}
const dateFmt = (locale: Locale, iso: string) => new Intl.DateTimeFormat(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(toDay(iso) * DAY + DAY / 2));
const pctFmt = (locale: Locale, n: number, digits = 1) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { style: 'percent', maximumFractionDigits: digits, minimumFractionDigits: digits }).format(n);
/** A whole percentage in the format of the language, 25 % or 25% */
const pc = (locale: Locale, n: number) => (locale === 'de' ? `${n} %` : `${n}%`);
const numFmt = (locale: Locale, n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
function Card({ title, description, action, children, className, bodyClassName }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string }) {
return (
<section className={cn('flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)}>
{(title || action) && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<div className="min-w-0">
{title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
{description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn(
'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative inline-flex items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
/** The browser select in the look of the kit fields */
function Pick({ label, value, onChange, children, className }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string }) {
return (
<span className={cn('relative block min-w-0', className)}>
<select aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} className="h-11 w-full min-w-0 cursor-pointer appearance-none truncate rounded-lg border border-input bg-background ps-3 pe-8 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 hover:border-border-strong focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]">
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Flip({ children, className }: { children: string; className?: string }) {
return (
<span className={cn('relative inline-flex overflow-hidden', className)}>
<span key={children} className="kitm-in [--kitm-t:.4s]">
{children}
</span>
</span>
);
}
function KeyChip({ k, t }: { k: string; t: Texts }) {
const [done, setDone] = useState(false);
useEffect(() => {
if (!done) return;
const id = window.setTimeout(() => setDone(false), 1400);
return () => window.clearTimeout(id);
}, [done]);
return (
<button
type="button"
aria-label={fill(t.copyKey, { key: k })}
onClick={async () => {
try {
await navigator.clipboard.writeText(k);
} catch {
/* The check still shows */
}
setDone(true);
}}
className="group/key relative inline-flex h-6 max-w-full items-center gap-1 rounded-md bg-muted px-1.5 text-[12px] tabular-nums text-muted-foreground transition-colors after:absolute after:-inset-y-2.5 after:inset-x-0 after:content-[''] hover:text-foreground"
>
<span className="truncate">{k}</span>
<Icon key={String(done)} name={done ? 'check' : 'copy'} size={11} className={cn('shrink-0', done ? 'kitm-tick text-success-foreground' : 'opacity-0 transition-opacity group-hover/key:opacity-100 group-focus-visible/key:opacity-100')} />
<span className="sr-only" role="status">
{done ? t.copied : ''}
</span>
</button>
);
}
function SaveBar({ show, dirty, busy, message, saveLabel, t, onDiscard, onSave }: { show: boolean; dirty: boolean; busy: boolean; message: string; saveLabel: string; t: Texts; onDiscard: () => void; onSave: () => void }) {
return (
<div className={cn('fixed inset-x-0 bottom-0 z-30 px-3 pb-[calc(env(safe-area-inset-bottom)+12px)] transition-[translate,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none sm:px-6', show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-6 opacity-0')} inert={!show}>
<div className="mx-auto flex max-w-[44rem] items-center gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl">
<p className="min-w-0 flex-1 truncate text-[13.5px] text-foreground">
{!dirty ? (
<span className="kitm-in flex items-center gap-2 text-success-foreground">
<Icon name="check-circle" size={16} className="shrink-0" />
<span className="truncate">{message}</span>
</span>
) : (
<span className="flex items-center gap-2">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-warning" />
<span className="truncate">{message}</span>
</span>
)}
</p>
{dirty && (
<>
<Button variant="ghost" size="sm" onClick={onDiscard}>
{t.discard}
</Button>
<Button size="sm" onClick={onSave} disabled={busy} iconLeft={busy ? 'loader' : undefined}>
{busy ? t.saving : saveLabel}
</Button>
</>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* flag-list */
/* ------------------------------------------------------------------ */
function FlagList({ t, title, subtitle, flags = [], environments = [], locale, onAction }: P) {
const [items, setItems] = useState(flags);
const [q, setQ] = useState('');
const [filter, setFilter] = useState<'all' | 'stale'>('all');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const shown = items.filter((x) => (filter === 'all' || x.stale) && (!q || `${x.name} ${x.key} ${(x.tags ?? []).join(' ')}`.toLowerCase().includes(q.toLowerCase())));
const toggle = (x: Flag, env: FlagEnv) => {
const v = !x.on[env.id];
setItems((l) => l.map((y) => (y.id === x.id ? { ...y, on: { ...y.on, [env.id]: v } } : y)));
setMsg(fill(v ? t.turnedOn : t.turnedOff, { flag: x.name, env: env.name }));
void run(onAction, 'toggle', { flag: x.id, env: env.id, on: v });
};
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Segmented
label={t.flag}
value={filter}
onChange={setFilter}
options={[
{ value: 'all', label: <>{t.all}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.length}</span></> },
{ value: 'stale', label: <>{t.stale}<span className="text-[12px] tabular-nums text-subtle-foreground">{items.filter((x) => x.stale).length}</span></> },
]}
/>
<Input aria-label={t.search} placeholder={t.search} icon="search" size="sm" type="search" value={q} onChange={(e) => setQ(e.target.value)} className="w-full sm:w-60" />
</Header>
<div ref={scope} {...enter} className="mt-6">
<div className="kitm-up overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
{/* Header row only where the environments line up as columns */}
<div className="hidden grid-cols-[minmax(0,1fr)_repeat(var(--envs),6.5rem)] items-center border-b border-border bg-surface px-5 py-2.5 text-[12px] font-medium text-muted-foreground md:grid" style={{ '--envs': environments.length } as React.CSSProperties}>
<span>{t.flag}</span>
{environments.map((e) => (
<span key={e.id} className="text-center">
{e.name}
</span>
))}
</div>
<ul className="divide-y divide-border">
{shown.map((x, i) => (
<li key={x.id} className="kitm-up grid grid-cols-1 gap-3 px-4 py-4 sm:px-5 md:grid-cols-[minmax(0,1fr)_repeat(var(--envs),6.5rem)] md:items-center md:gap-0" style={{ '--envs': environments.length, '--kitm-d': `${i * 50}ms` } as React.CSSProperties}>
<div className="min-w-0 md:pe-4">
<p className="flex flex-wrap items-center gap-2">
<span className="text-[14px] font-medium text-foreground">{x.name}</span>
{x.stale && (
<span title={t.staleHint} className="inline-flex items-center gap-1 rounded bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] px-1.5 py-px text-[11px] font-semibold text-warning-foreground">
<Icon name="hourglass" size={11} />
{t.stale}
</span>
)}
{x.rollout !== undefined && x.rollout < 100 && x.on[environments.at(-1)?.id ?? ''] && <span className="rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1.5 py-px text-[11px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{fill(t.rolloutN, { n: x.rollout })}</span>}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[12px] text-subtle-foreground">
<KeyChip k={x.key} t={t} />
{x.owner && <span>{x.owner}</span>}
{x.updated && <span>{fill(t.updated, { date: dateFmt(locale, x.updated) })}</span>}
</div>
</div>
{/* Phones: the environments as a row of labelled switches */}
<div className="grid grid-cols-3 gap-2 md:contents">
{environments.map((e) => (
<div key={e.id} className="flex flex-col items-center gap-1.5 rounded-lg bg-surface py-2 md:bg-transparent md:py-0">
<span className="text-[11.5px] text-muted-foreground md:hidden">{e.name}</span>
<Switch size="sm" checked={Boolean(x.on[e.id])} aria-label={fill(t.toggle, { flag: x.name, env: e.name })} onCheckedChange={() => toggle(x, e)} />
</div>
))}
</div>
</li>
))}
</ul>
{!shown.length && (
<div className="px-4 py-12 text-center">
<p className="text-[14px] text-muted-foreground">{t.noMatch}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => {
setQ('');
setFilter('all');
}}
>
{t.clear}
</Button>
</div>
)}
</div>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* rollout */
/* ------------------------------------------------------------------ */
/** Which of the 100 people get the feature: a fixed, even scatter so a higher share only adds people */
const ORDER = (() => {
// A seeded shuffle (mulberry32, public domain), the same on server and browser
let seed = 20261019;
const rnd = () => {
seed = (seed + 0x6d2b79f5) >>> 0;
let x = seed;
x = Math.imul(x ^ (x >>> 15), x | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
const a = Array.from({ length: 100 }, (_, i) => i);
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
})();
const RANK = ORDER.reduce<number[]>((acc, cell, rank) => ((acc[cell] = rank), acc), []);
function Rollout({ t, title, subtitle, flag, stages = [5, 25, 50, 100], locale, onAction }: P) {
const id = useId();
const start = flag?.rollout ?? 0;
const [saved, setSaved] = useState(start);
const [n, setN] = useState(start);
const [killed, setKilled] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const next = stages.find((s) => s > n);
const save = async (v = n) => {
setBusy(true);
await run(onAction, 'rollout', { flag: flag?.id, percent: v });
setSaved(v);
setN(v);
setBusy(false);
setMsg(fill(t.saved, { n: v }));
};
return (
<Main className="pb-28">
<Header title={title} subtitle={subtitle}>
{flag && <KeyChip k={flag.key} t={t} />}
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_21rem] lg:items-start">
<Card title={t.rollout} description={t.rolloutText} className="kitm-up">
{/* Side by side only where the card is wide enough, the narrow lg column stacks */}
<div className="@container">
<div className={cn('grid gap-6 transition-opacity duration-300 @xl:grid-cols-[minmax(0,1fr)_14rem] @xl:items-center', killed && 'opacity-45')}>
<div>
<p className="flex items-baseline gap-3">
<span className="text-[44px] font-semibold leading-none tracking-[-0.04em] tabular-nums text-foreground">
<Flip>{pc(locale, n)}</Flip>
</span>
<span className="text-[13px] tabular-nums text-muted-foreground">{fill(t.users, { n })}</span>
</p>
<label htmlFor={`${id}-r`} className="sr-only">
{t.rollout}
</label>
<input id={`${id}-r`} type="range" min={0} max={100} step={1} value={n} disabled={killed} aria-valuetext={pc(locale, n)} onChange={(e) => setN(Number(e.target.value))} className="mt-5 h-11 w-full cursor-pointer accent-[var(--primary)] disabled:cursor-not-allowed" />
<div className="mt-1 flex flex-wrap gap-1.5">
{[0, ...stages].map((s) => (
<button key={s} type="button" disabled={killed} onClick={() => setN(s)} className={cn('h-11 min-w-11 rounded-md border px-2.5 text-[12.5px] font-medium tabular-nums transition-colors sm:h-8 sm:min-w-0', n === s ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
{pc(locale, s)}
</button>
))}
</div>
</div>
{/* 100 people, filled ones get the feature. The same people stay in as the share grows. */}
<div aria-hidden className="mx-auto grid w-full max-w-[14rem] grid-cols-10 gap-1.5">
{Array.from({ length: 100 }, (_, i) => (
<span key={i} className={cn('aspect-square rounded-full transition-[background-color,transform] duration-300 ease-[var(--ease-kit)]', RANK[i] < n ? 'scale-100 bg-primary' : 'scale-75 bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]')} style={{ transitionDelay: `${(RANK[i] % 10) * 12}ms` }} />
))}
</div>
</div>
</div>
</Card>
<div className="grid gap-6 lg:sticky lg:top-6">
<Card title={t.stagesTitle} description={t.stagesText} className="kitm-up [--kitm-d:100ms]">
<ol className="relative grid gap-3">
<span aria-hidden className="absolute bottom-3 start-[11px] top-3 w-0.5 rounded-full bg-muted" />
<span aria-hidden className="absolute start-[11px] top-3 w-0.5 origin-top rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)]" style={{ height: 'calc(100% - 1.5rem)', transform: `scaleY(${round(Math.max(0, stages.filter((s) => s <= saved).length - 1) / Math.max(1, stages.length - 1), 3)})` }} />
{stages.map((s, i) => {
const reached = saved >= s;
const isNow = reached && (stages[i + 1] === undefined || saved < stages[i + 1]);
return (
<li key={s} className="relative flex items-center gap-3">
<span className={cn('relative z-[1] grid size-6 shrink-0 place-items-center rounded-full border-2 transition-colors duration-300', reached ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-card')}>{reached && <Icon name="check" size={12} strokeWidth={2.6} />}</span>
<span className="flex-1 text-[13.5px] text-foreground">
{fill(t.stage, { n: i + 1 })} · <span className="tabular-nums">{pc(locale, s)}</span>
</span>
{isNow && <span className="rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1.5 py-px text-[11px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.current}</span>}
</li>
);
})}
</ol>
<Button className="mt-5 w-full" variant="outline" disabled={!next || killed || busy || n !== saved} onClick={() => next && save(next)} iconLeft="arrow-up">
{next ? fill(t.nextStep, { n: next }) : t.full}
</Button>
</Card>
<Card className={cn('kitm-up [--kitm-d:160ms]', killed && 'border-[color-mix(in_oklab,var(--destructive)_40%,var(--border))]')}>
<p className="flex items-center gap-2 text-[14px] font-semibold text-foreground">
<Icon name="power" size={16} className={killed ? 'text-destructive' : 'text-muted-foreground'} />
{t.kill}
</p>
<p key={String(killed)} className="kitm-in mt-1 text-pretty text-[13px] leading-snug text-muted-foreground">
{killed ? fill(t.killed, { n: saved }) : t.killText}
</p>
<Button
size="sm"
variant={killed ? 'primary' : 'destructive'}
className="mt-4 w-full"
onClick={() => {
setKilled(!killed);
void run(onAction, killed ? 'resume' : 'kill', { flag: flag?.id });
}}
>
{killed ? t.resume : t.kill}
</Button>
</Card>
</div>
</div>
<SaveBar show={n !== saved || Boolean(msg)} dirty={n !== saved} busy={busy} message={n !== saved ? `${pc(locale, saved)} → ${pc(locale, n)}` : msg} saveLabel={t.save} t={t} onDiscard={() => setN(saved)} onSave={() => save()} />
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* targeting */
/* ------------------------------------------------------------------ */
const OPS: TargetRule['operator'][] = ['is', 'is-not', 'contains', 'ends-with', 'in'];
function matches(r: TargetRule, v: string) {
const a = v.trim().toLowerCase();
const b = r.value.trim().toLowerCase();
if (!b) return false;
switch (r.operator) {
case 'is':
return a === b;
case 'is-not':
return a !== b;
case 'contains':
return a.includes(b);
case 'ends-with':
return a.endsWith(b);
case 'in':
return b.split(/\s*,\s*/).includes(a);
}
}
function Targeting({ t, title, subtitle, flag, rules = [], attributes = [], variants = ['on', 'off'], onAction }: P) {
const id = useId();
const [saved, setSaved] = useState(rules);
const [list, setList] = useState(rules);
const [fallback, setFallback] = useState(variants.at(-1) ?? '');
const [savedFallback, setSavedFallback] = useState(variants.at(-1) ?? '');
const [probe, setProbe] = useState<Record<string, string>>(() => Object.fromEntries(attributes.map((a) => [a.id, a.placeholder ?? ''])));
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const listRef = useRef<HTMLOListElement>(null);
const dirty = JSON.stringify(list) !== JSON.stringify(saved) || fallback !== savedFallback;
const hit = list.findIndex((r) => matches(r, probe[r.attribute] ?? ''));
const set = (rid: string, patch: Partial<TargetRule>) => {
setMsg('');
setList((l) => l.map((r) => (r.id === rid ? { ...r, ...patch } : r)));
};
// Rules glide to their new place when moved
const before = useRef<Map<string, number>>(new Map());
if (typeof window !== 'undefined' && listRef.current) before.current = new Map(Array.from(listRef.current.children, (el) => [(el as HTMLElement).dataset.id ?? '', el.getBoundingClientRect().top]));
useIso(() => {
if (!listRef.current || prefersReducedMotion()) return;
Array.from(listRef.current.children).forEach((el) => {
const a = before.current.get((el as HTMLElement).dataset.id ?? '');
const b = el.getBoundingClientRect().top;
if (a !== undefined && Math.abs(a - b) > 1) el.animate([{ transform: `translateY(${a - b}px)` }, { transform: 'none' }], { duration: 420, easing: KITM_EASE });
});
}, [list.map((r) => r.id).join()]);
const move = (i: number, d: number) => {
const j = i + d;
if (j < 0 || j >= list.length) return;
setList((l) => {
const c = [...l];
[c[i], c[j]] = [c[j], c[i]];
return c;
});
};
const attrLabel = (a: string) => attributes.find((x) => x.id === a)?.label ?? a;
return (
<Main className="pb-28">
<Header title={title} subtitle={subtitle}>
{flag && <KeyChip k={flag.key} t={t} />}
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<Card title={t.rules} description={t.rulesText} className="kitm-up" bodyClassName="p-0 sm:p-0">
<ol ref={listRef} className="divide-y divide-border">
{list.map((r, i) => (
<li key={r.id} data-id={r.id} className={cn('relative bg-card px-4 py-4 transition-colors duration-300 sm:px-5', hit === i && 'bg-[color-mix(in_oklab,var(--success)_6%,var(--card))]')}>
{hit === i && <span aria-hidden className="absolute inset-y-0 start-0 w-0.5 bg-success" />}
<div className="flex items-start gap-3">
<span className="mt-2.5 grid size-6 shrink-0 place-items-center rounded-full bg-muted text-[11.5px] font-semibold tabular-nums text-muted-foreground">{i + 1}</span>
<div className="grid min-w-0 flex-1 grid-cols-2 gap-2 sm:grid-cols-[auto_minmax(0,1fr)_minmax(0,0.9fr)_minmax(0,1.2fr)] sm:items-center">
<span className="hidden text-[12.5px] font-medium text-muted-foreground sm:block">{t.if}</span>
<Pick label={t.attribute} value={r.attribute} onChange={(v) => set(r.id, { attribute: v })}>
{attributes.map((a) => (
<option key={a.id} value={a.id}>
{a.label}
</option>
))}
</Pick>
<Pick label={t.operator} value={r.operator} onChange={(v) => set(r.id, { operator: v as TargetRule['operator'] })}>
{OPS.map((o) => (
<option key={o} value={o}>
{t[o]}
</option>
))}
</Pick>
<input aria-label={t.value} value={r.value} placeholder={t.emptyValue} onChange={(e) => set(r.id, { value: e.target.value })} className={cn('col-span-2 h-11 min-w-0 rounded-lg border bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:col-span-1 sm:h-9 sm:text-[13px]', r.value.trim() ? 'border-input' : 'border-[color-mix(in_oklab,var(--warning)_70%,var(--border))]')} />
</div>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2 ps-9">
<span className="text-[12.5px] font-medium text-muted-foreground">{t.serve}</span>
<Segmented label={t.variant} value={r.serve} onChange={(v) => set(r.id, { serve: v })} options={variants.map((v) => ({ value: v, label: v }))} />
<span className="ms-auto flex items-center">
<button type="button" aria-label={fill(t.moveUp, { n: i + 1 })} disabled={i === 0} onClick={() => move(i, -1)} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
<Icon name="chevron-up" size={16} />
</button>
<button type="button" aria-label={fill(t.moveDown, { n: i + 1 })} disabled={i === list.length - 1} onClick={() => move(i, 1)} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30 sm:size-8">
<Icon name="chevron-down" size={16} />
</button>
<button type="button" aria-label={fill(t.removeRule, { n: i + 1 })} onClick={() => setList((l) => l.filter((x) => x.id !== r.id))} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-8">
<Icon name="trash" size={15} />
</button>
</span>
</div>
</li>
))}
</ol>
<div className="border-t border-border px-4 py-3 sm:px-5">
<Button variant="ghost" size="sm" iconLeft="plus" onClick={() => setList((l) => [...l, { id: `r${Date.now()}`, attribute: attributes[0]?.id ?? '', operator: 'is', value: '', serve: variants[0] ?? '' }])}>
{t.addRule}
</Button>
</div>
<div className={cn('flex flex-wrap items-center gap-3 border-t border-border bg-surface px-4 py-4 sm:px-5', hit < 0 && 'bg-[color-mix(in_oklab,var(--success)_6%,var(--surface))]')}>
<span className="text-[13px] font-medium text-foreground">{t.everyoneElse}</span>
<Segmented label={t.everyoneElse} value={fallback} onChange={setFallback} options={variants.map((v) => ({ value: v, label: v }))} />
</div>
</Card>
{/* Desktop: a test person next to the rules. Tablets and phones: below them. */}
<Card title={t.test} description={t.testText} className="kitm-up [--kitm-d:120ms] lg:sticky lg:top-6">
<div className="grid gap-3">
{attributes.map((a) => (
<label key={a.id} htmlFor={`${id}-${a.id}`} className="grid gap-1.5 text-[13px] font-medium text-foreground">
{a.label}
<input id={`${id}-${a.id}`} value={probe[a.id] ?? ''} onChange={(e) => setProbe((p) => ({ ...p, [a.id]: e.target.value }))} className="h-11 rounded-lg border border-input bg-background px-3 text-base font-normal text-foreground outline-none focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-sm" />
</label>
))}
</div>
<p key={`${hit}-${hit >= 0 ? list[hit].serve : fallback}`} role="status" className="kitm-in mt-4 flex items-start gap-2.5 rounded-lg bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] p-3 text-[13px] leading-snug text-foreground">
<Icon name="target" size={15} className="mt-px shrink-0 text-success-foreground" />
<span>
{hit >= 0 ? fill(t.matches, { n: hit + 1, v: list[hit].serve }) : fill(t.noRule, { v: fallback })}
{hit >= 0 && <span className="mt-0.5 block text-[12px] text-muted-foreground">{`${attrLabel(list[hit].attribute)} ${t[list[hit].operator]} ${list[hit].value}`}</span>}
</span>
</p>
</Card>
</div>
<SaveBar
show={dirty || Boolean(msg)}
dirty={dirty}
busy={busy}
message={dirty ? t.unsavedRules : msg}
saveLabel={t.saveRules}
t={t}
onDiscard={() => {
setList(saved);
setFallback(savedFallback);
}}
onSave={async () => {
setBusy(true);
await run(onAction, 'save-rules', { flag: flag?.id, rules: list, fallback });
setSaved(list);
setSavedFallback(fallback);
setBusy(false);
setMsg(t.savedRules);
}}
/>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* experiment */
/* ------------------------------------------------------------------ */
/** Two-sided normal test of the rate difference, returns the confidence 0 to 1 */
function confidence(a: ExperimentArm, b: ExperimentArm) {
const p1 = a.conversions / Math.max(1, a.visitors);
const p2 = b.conversions / Math.max(1, b.visitors);
const p = (a.conversions + b.conversions) / Math.max(1, a.visitors + b.visitors);
const se = Math.sqrt(p * (1 - p) * (1 / Math.max(1, a.visitors) + 1 / Math.max(1, b.visitors)));
if (!se) return 0;
const z = Math.abs(p2 - p1) / se;
// Normal CDF by a short series (Abramowitz and Stegun 7.1.26)
const tt = 1 / (1 + 0.3275911 * (z / Math.SQRT2));
const erf = 1 - ((((1.061405429 * tt - 1.453152027) * tt + 1.421413741) * tt - 0.284496736) * tt + 0.254829592) * tt * Math.exp(-(z * z) / 2);
return erf;
}
function Experiment({ t, title, subtitle, flag, arms = [], metric, locale, onAction }: P) {
const [split, setSplit] = useState(arms[0]?.share ?? 50);
const [saved, setSaved] = useState(arms[0]?.share ?? 50);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const trackRef = useRef<HTMLDivElement>(null);
const control = arms.find((a) => a.control) ?? arms[0];
const rate = (a: ExperimentArm) => a.conversions / Math.max(1, a.visitors);
const best = arms.reduce((b, a) => (rate(a) > rate(b) ? a : b), arms[0]);
const conf = control && best && best !== control ? confidence(control, best) : 0;
const maxRate = Math.max(...arms.map(rate), 0.0001);
const drag = (clientX: number) => {
const r = trackRef.current?.getBoundingClientRect();
if (!r) return;
setSplit(Math.max(5, Math.min(95, Math.round(((clientX - r.left) / r.width) * 100))));
};
return (
<Main className="pb-28">
<Header title={title} subtitle={subtitle}>
{flag && <KeyChip k={flag.key} t={t} />}
</Header>
<div ref={scope} {...enter} className="mt-6 grid gap-6">
<Card title={t.split} description={t.splitText} className="kitm-up">
{arms.length >= 2 && (
<>
<div className="flex justify-between text-[13px] font-medium text-foreground">
<span>
{arms[0].name} <span className="tabular-nums text-muted-foreground">{pc(locale, split)}</span>
</span>
<span>
{arms[1].name} <span className="tabular-nums text-muted-foreground">{pc(locale, 100 - split)}</span>
</span>
</div>
<div
ref={trackRef}
className="relative mt-3 flex h-12 touch-none select-none overflow-hidden rounded-lg"
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag(e.clientX);
}}
onPointerMove={(e) => e.buttons === 1 && drag(e.clientX)}
>
<span className="h-full bg-[color-mix(in_oklab,var(--foreground)_22%,var(--card))] transition-[flex-basis] duration-150" style={{ flexBasis: `${split}%` }} />
<span className="h-full flex-1 bg-primary" />
<span
role="slider"
tabIndex={0}
aria-label={fill(t.shareOf, { name: arms[0].name })}
aria-valuemin={5}
aria-valuemax={95}
aria-valuenow={split}
aria-valuetext={`${arms[0].name} ${pc(locale, split)}, ${arms[1].name} ${pc(locale, 100 - split)}`}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowUp' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowDown' ? -1 : 0;
if (!d) return;
e.preventDefault();
setSplit((s) => Math.max(5, Math.min(95, s + d * (e.shiftKey ? 10 : 1))));
}}
className="absolute inset-y-1 grid w-3 -translate-x-1/2 cursor-ew-resize place-items-center rounded-md bg-card shadow-[var(--shadow-md)] outline-none ring-1 ring-border focus-visible:ring-2 focus-visible:ring-primary"
style={{ left: `${split}%` }}
>
<span className="h-5 w-0.5 rounded-full bg-border-strong" />
</span>
</div>
</>
)}
</Card>
<section className="kitm-up [--kitm-d:100ms] grid gap-4 md:grid-cols-2">
{arms.map((a, i) => {
const r = rate(a);
const lift = control && a !== control ? r / Math.max(0.0001, rate(control)) - 1 : 0;
const isBest = a === best && a !== control;
return (
<article key={a.id} className={cn('relative rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5', isBest && conf >= 0.95 ? 'border-[color-mix(in_oklab,var(--success)_50%,var(--border))]' : 'border-border')}>
<div className="flex items-start justify-between gap-3">
<p className="flex items-center gap-2 text-[15px] font-semibold text-foreground">
<span aria-hidden className={cn('size-2.5 rounded-sm', i === 0 ? 'bg-[color-mix(in_oklab,var(--foreground)_40%,var(--card))]' : 'bg-primary')} />
{a.name}
{a.control && <span className="rounded bg-muted px-1.5 py-px text-[11px] font-medium text-muted-foreground">{t.control}</span>}
</p>
{isBest && (
<span className={cn('rounded px-1.5 py-0.5 text-[11.5px] font-semibold', conf >= 0.95 ? 'bg-[color-mix(in_oklab,var(--success)_15%,transparent)] text-success-foreground' : 'bg-muted text-muted-foreground')}>
{conf >= 0.95 ? t.winner : t.tooEarly} · {fill(t.confidence, { n: Math.round(conf * 100) })}
</span>
)}
</div>
<p className="mt-4 flex items-baseline gap-3">
<span className="text-[32px] font-semibold leading-none tracking-[-0.03em] tabular-nums text-foreground">{pctFmt(locale, r)}</span>
<span className="text-[13px] text-muted-foreground">{metric ?? t.rate}</span>
{a !== control && (
<span className={cn('text-[13px] font-semibold tabular-nums', lift >= 0 ? 'text-success-foreground' : 'text-destructive')}>
{lift >= 0 ? '+' : '−'}
{pctFmt(locale, Math.abs(lift))} <span className="font-normal text-muted-foreground">{t.lift}</span>
</span>
)}
</p>
<span aria-hidden className="mt-4 block h-2 overflow-hidden rounded-full bg-track">
<span className={cn('kitm-grow-x block h-full rounded-full', i === 0 ? 'bg-[color-mix(in_oklab,var(--foreground)_40%,var(--card))]' : 'bg-primary')} style={{ width: `${round((r / maxRate) * 100)}%`, '--kitm-d': `${200 + i * 100}ms` } as React.CSSProperties} />
</span>
<dl className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border text-[13px]">
<div className="bg-surface p-3">
<dt className="text-[12px] text-muted-foreground">{t.visitors}</dt>
<dd className="mt-0.5 font-medium tabular-nums text-foreground">{numFmt(locale, a.visitors)}</dd>
</div>
<div className="bg-surface p-3">
<dt className="text-[12px] text-muted-foreground">{t.conversions}</dt>
<dd className="mt-0.5 font-medium tabular-nums text-foreground">{numFmt(locale, a.conversions)}</dd>
</div>
</dl>
</article>
);
})}
</section>
</div>
<SaveBar
show={split !== saved || Boolean(msg)}
dirty={split !== saved}
busy={busy}
message={split !== saved ? `${saved} / ${100 - saved} → ${split} / ${100 - split}` : msg}
saveLabel={t.applySplit}
t={t}
onDiscard={() => setSplit(saved)}
onSave={async () => {
setBusy(true);
await run(onAction, 'split', { flag: flag?.id, split });
setSaved(split);
setBusy(false);
setMsg(fill(t.splitSaved, { a: split, b: 100 - split }));
}}
/>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* environments */
/* ------------------------------------------------------------------ */
function Environments({ t, title, subtitle, flag, environments = [], configs = [], locale, today, onAction }: P) {
const [list, setList] = useState(configs);
const [busy, setBusy] = useState('');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const envName = (e: string) => environments.find((x) => x.id === e)?.name ?? e;
const fields: { key: keyof EnvConfig; label: string; show: (c: EnvConfig) => React.ReactNode }[] = [
{ key: 'on', label: t.status, show: (c) => <span className={cn('inline-flex items-center gap-1.5 font-medium', c.on ? 'text-success-foreground' : 'text-muted-foreground')}><span aria-hidden className={cn('size-1.5 rounded-full', c.on ? 'bg-success' : 'bg-subtle-foreground')} />{c.on ? t.on : t.off}</span> },
{ key: 'rollout', label: t.rollout, show: (c) => <span className="tabular-nums">{pc(locale, c.rollout)}</span> },
{ key: 'rules', label: t.rules, show: (c) => <span className="tabular-nums">{c.rules === 1 ? t.rulesOne : fill(t.rulesN, { n: c.rules })}</span> },
{ key: 'fallback', label: t.fallback, show: (c) => c.fallback },
];
const differs = (i: number, k: keyof EnvConfig) => i > 0 && list[i][k] !== list[i - 1][k];
const promote = async (i: number) => {
const from = list[i - 1];
const to = list[i];
setBusy(to.env);
await run(onAction, 'promote', { flag: flag?.id, from: from.env, to: to.env });
setList((l) => l.map((c, j) => (j === i ? { ...c, on: from.on, rollout: from.rollout, rules: from.rules, fallback: from.fallback, updated: today, by: t.you } : c)));
setBusy('');
setMsg(fill(t.promoted, { to: envName(to.env), from: envName(from.env) }));
};
return (
<Main>
<Header title={title} subtitle={subtitle}>
{flag && <KeyChip k={flag.key} t={t} />}
</Header>
<p className="mt-2 text-[13.5px] text-muted-foreground">{t.envsText}</p>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[repeat(var(--n),minmax(0,1fr))] lg:gap-0" style={{ '--n': list.length } as React.CSSProperties}>
{list.map((c, i) => {
const changed = fields.some((x) => differs(i, x.key));
return (
<div key={c.env} className="contents">
<article className={cn('kitm-up relative flex min-w-0 flex-col rounded-xl border bg-card shadow-[var(--highlight),var(--shadow-sm)]', changed ? 'border-[color-mix(in_oklab,var(--warning)_45%,var(--border))]' : 'border-border', 'lg:mx-2.5 lg:first:ms-0 lg:last:me-0')} style={{ '--kitm-d': `${i * 90}ms` } as React.CSSProperties}>
<header className="flex items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<h2 className="text-[15px] font-semibold text-foreground">{envName(c.env)}</h2>
{i > 0 && (
<span key={String(changed)} className={cn('kitm-in rounded px-1.5 py-0.5 text-[11.5px] font-semibold', changed ? 'bg-[color-mix(in_oklab,var(--warning)_20%,transparent)] text-warning-foreground' : 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground')}>
{changed ? fill(t.differs, { env: envName(list[i - 1].env) }) : fill(t.same, { env: envName(list[i - 1].env) })}
</span>
)}
</header>
<dl className="flex-1 divide-y divide-border">
{fields.map((x) => (
<div key={x.key} className={cn('flex items-center justify-between gap-3 px-4 py-3 text-[13.5px] transition-colors duration-500 sm:px-5', differs(i, x.key) && 'bg-[color-mix(in_oklab,var(--warning)_9%,transparent)]')}>
<dt className="text-muted-foreground">{x.label}</dt>
<dd key={String(c[x.key])} className="kitm-in min-w-0 truncate text-right text-foreground">
{x.show(c)}
</dd>
</div>
))}
</dl>
<footer className="border-t border-border px-4 py-3 text-[12px] text-subtle-foreground sm:px-5">
{c.updated ? `${fill(t.updated, { date: dateFmt(locale, c.updated) })}${c.by ? ` · ${fill(t.by, { name: c.by })}` : ''}` : '·'}
</footer>
</article>
{/* Between two environments: the way to copy the left one over */}
{i < list.length - 1 && list.length > 1 && (
<div className="flex justify-center lg:hidden">
<Button size="sm" variant="outline" iconLeft={busy === list[i + 1].env ? 'loader' : 'arrow-down'} disabled={!fields.some((x) => differs(i + 1, x.key)) || Boolean(busy)} onClick={() => promote(i + 1)}>
{busy === list[i + 1].env ? t.promoting : fill(t.promote, { from: envName(c.env), to: envName(list[i + 1].env) })}
</Button>
</div>
)}
</div>
);
})}
</div>
{/* Desktop: the copy actions in a row under the columns */}
<div className="mt-4 hidden lg:grid lg:grid-cols-[repeat(var(--n),minmax(0,1fr))]" style={{ '--n': list.length } as React.CSSProperties}>
<span />
{list.slice(1).map((c, j) => (
<div key={c.env} className="flex justify-center px-2.5">
<Button size="sm" variant="outline" iconLeft={busy === c.env ? 'loader' : 'arrow-right'} disabled={!fields.some((x) => differs(j + 1, x.key)) || Boolean(busy)} onClick={() => promote(j + 1)}>
{busy === c.env ? t.promoting : fill(t.promote, { from: envName(list[j].env), to: envName(c.env) })}
</Button>
</div>
))}
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* FeatureFlags */
/* ------------------------------------------------------------------ */
/**
* Feature flag screens: a list with switches per environment, a staged
* rollout with a kill switch, targeting rules with a test person, an A/B
* split with results and a comparison across environments with copy.
* <FeatureFlags variant="flag-list" title="Flags" environments={…} flags={…} />
*/
export function FeatureFlags({ variant = 'flag-list', sidebar, labels, className, today = '2026-10-19', ...props }: FeatureFlagsProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const p: P = { ...props, t, today, locale };
const body = (() => {
switch (variant) {
case 'rollout':
return <Rollout {...p} />;
case 'targeting':
return <Targeting {...p} />;
case 'experiment':
return <Experiment {...p} />;
case 'environments':
return <Environments {...p} />;
default:
return <FlagList {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<KitmStyle />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<KitmStyle />
{body}
</Sidebar>
);
}Frequent questions
How do I install Feature flags?
With aiw add @ai-website/feature-flags. The command puts components/ui/feature-flags.tsx into your project, the code is yours from then on. It brings along button, icon, input, switch and sidebar. It needs no further npm packages.
Which variants does Feature flags have?
5 variants, Flag list, Staged rollout, Targeting rules, A/B experiment and Environments. You pick one with the variant prop, for example variant="rollout".
Can I translate the texts of Feature flags?
Yes. Feature flags carries 86 texts of its own in English and German, such as “Search flags”, “Discard” and “All”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Feature flags?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Feature flags with aiw_components and install it with aiw_add @ai-website/feature-flags. Name the variant in your prompt, for example variant="flag-list", and the assistant writes your content straight into the props.
Can I adjust Feature flags without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching FeatureFlags code to copy.