Resources/Agent Skills
ui-variant-prototype
Scaffold temporary multi-layout UI prototypes with persisted variant switching and a floating preview toggle. Use when comparing 2–5 design options for a component, the user asks to try alternatives before picking one, or when building A/B-style layout previews in React apps.
UI Variant Prototype
Temporary workflow for comparing visual/layout options in the running app, then collapsing to a single implementation when the user picks a winner.
Stack: Any React setup (Vite SPA, Next.js, Remix, etc.). Examples below use shadcn/ui and Tailwind for the toggle UI — swap for the app's own component library and styling — and whatever state management the app already uses (Jotai, Zustand, Redux, React Context — see Preview state).
Everything below is client-side code. If the app uses React Server Components (e.g. Next.js App Router), add 'use client' at the top of each preview file; in other setups, omit it.
When to use
- User wants to see 2–5 layout options side-by-side in context (not Figma-only).
- The decision is presentational (spacing, typography, tile vs inline, icon treatment)—not business logic.
- You will delete preview machinery after one variant ships.
Do not use for: API design, routing, or long-lived feature flags.
File layout
Colocate under the owning feature folder (route _components, app-components, etc.):
feature-name/
├── feature-name.tsx # router: reads variant state, renders active variant
├── feature-name-data.ts # shared data hook / pure mappers (no layout)
├── feature-name-preview.state.ts # variant union + persisted state + labels
├── feature-name-preview-toggle.tsx # floating switcher (dev-only)
├── feature-name-shared.tsx # optional: icons, value formatting, a11y helpers
└── variants/
├── feature-name-variant-a.tsx
├── feature-name-variant-b.tsx
└── ...
Use explicit entry files (feature-name.tsx), not index.ts barrels.
Phase 1 — Extract shared data
- Move fetching, context reads, and formatting into
feature-name-data.ts. - Export a stable props shape variants consume, e.g.
FeatureItem[]with{ id, label, value, isEmpty }. - Keep variants dumb: layout + markup only; no duplicate business logic.
// feature-name-data.ts
export type FeatureItem = { key: string; label: string; value: string; isEmpty: boolean };
export function useFeatureItems(): FeatureItem[] {
// context, hooks, formatters
}
Phase 2 — Implement variants
- Add one file per option under
variants/. - Each exports a single component:
FeatureNameVariantA({ items }: { items: FeatureItem[] }). - Share markup via
feature-name-shared.tsxwhen variants differ only in wrapper/layout (icons, tooltips, empty states). - Keep variants visually distinct—avoid five nearly identical tweaks.
Phase 3 — Preview infrastructure
Preview state (feature-name-preview.state.ts)
The only requirement: the chosen variant must persist (survive a reload/navigation) so you can compare options while browsing the real app. Any state approach works as long as it reads/writes a small string union and persists it to localStorage or equivalent. Pick whatever the project already uses:
- Already on Jotai? Use
atomWithStorage— one line, persistence included. - Already on Zustand? Use the
persistmiddleware. - Already on Redux? A slice + a
localStorage-syncing subscriber, orredux-persist. - No global state library? A small React Context provider wrapping
useState+localStorage. A bareuseStatehook is not enough: the toggle and the router are separate components, so each would get its own copy and switching wouldn't update the feature live — the state must be shared through a provider (or a store).
Whatever you pick, expose the same three things: the variant union/type, a read+write accessor, and a labels map for the toggle UI.
Example implementation (Jotai) — swap for your library of choice:
import { atomWithStorage } from 'jotai/utils';
export const FEATURE_PREVIEW_VARIANTS = ['a', 'b', 'c'] as const;
export type FeaturePreviewVariant = (typeof FEATURE_PREVIEW_VARIANTS)[number];
export const featurePreviewVariantAtom = atomWithStorage<FeaturePreviewVariant>(
'my-app:dashboard-feature-preview-variant-v1', // bump suffix when union changes
'a',
);
export const FEATURE_PREVIEW_LABELS: Record<FeaturePreviewVariant, string> = {
a: 'Option A',
b: 'Option B',
c: 'Option C',
};
Example implementation (plain React, no library) — the file becomes feature-name-preview.state.tsx since it renders a provider:
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
export const FEATURE_PREVIEW_VARIANTS = ['a', 'b', 'c'] as const;
export type FeaturePreviewVariant = (typeof FEATURE_PREVIEW_VARIANTS)[number];
const STORAGE_KEY = 'my-app:dashboard-feature-preview-variant-v1';
type PreviewContextValue = readonly [FeaturePreviewVariant, (v: FeaturePreviewVariant) => void];
const FeaturePreviewContext = createContext<PreviewContextValue | null>(null);
export function FeaturePreviewProvider({ children }: { children: ReactNode }) {
// Start from the default and read localStorage after mount — under SSR
// (Next.js, Remix, ...) localStorage doesn't exist at render time, and a
// render-time read would mismatch on hydration. Harmless in a pure SPA.
const [variant, setVariant] = useState<FeaturePreviewVariant>('a');
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as FeaturePreviewVariant | null;
if (stored && FEATURE_PREVIEW_VARIANTS.includes(stored)) setVariant(stored);
}, []);
const setAndPersist = (v: FeaturePreviewVariant) => {
setVariant(v);
localStorage.setItem(STORAGE_KEY, v);
};
return (
<FeaturePreviewContext.Provider value={[variant, setAndPersist] as const}>
{children}
</FeaturePreviewContext.Provider>
);
}
export function useFeaturePreviewVariant(): PreviewContextValue {
const value = useContext(FeaturePreviewContext);
if (!value) throw new Error('useFeaturePreviewVariant requires FeaturePreviewProvider');
return value;
}
export const FEATURE_PREVIEW_LABELS: Record<FeaturePreviewVariant, string> = {
a: 'Option A',
b: 'Option B',
c: 'Option C',
};
- Storage key:
{project-or-app}:{area}-{feature}-preview-variant-v{N}. - Bump
v{N}if you add/remove/rename variants (avoids stale localStorage).
Router (feature-name.tsx)
import type { ComponentType } from 'react';
import { useFeatureItems } from './feature-name-data';
// Jotai example — replace with your state hook of choice:
import { useAtomValue } from 'jotai';
import { featurePreviewVariantAtom, type FeaturePreviewVariant } from './feature-name-preview.state';
import { FeatureNameVariantA } from './variants/feature-name-variant-a';
// ...
const variantComponents: Record<
FeaturePreviewVariant,
ComponentType<{ items: ReturnType<typeof useFeatureItems> }>
> = {
a: FeatureNameVariantA,
b: FeatureNameVariantB,
c: FeatureNameVariantC,
};
export function FeatureName() {
const variant = useAtomValue(featurePreviewVariantAtom);
const items = useFeatureItems();
const Variant = variantComponents[variant];
return <Variant items={items} />;
}
Floating toggle (feature-name-preview-toggle.tsx)
Mount once on the page/layout, inside whatever provider your state approach needs (Jotai Provider, Zustand doesn't need one, Context needs its own provider, etc.)—sibling to the feature component.
import { useAtom } from 'jotai'; // swap for your state hook
import { Button } from '@/components/ui/button'; // shadcn/ui — swap for the app's button
import { cn } from '@/lib/utils';
import {
FEATURE_PREVIEW_LABELS,
FEATURE_PREVIEW_VARIANTS,
featurePreviewVariantAtom,
} from './feature-name-preview.state';
// Static class names — Tailwind can't generate CSS for `grid-cols-${n}` template literals.
const GRID_COLS: Record<number, string> = {
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
};
export function FeatureNamePreviewToggle() {
const [variant, setVariant] = useAtom(featurePreviewVariantAtom);
return (
<div
className="border-border bg-card/95 fixed right-4 bottom-4 z-50 flex max-w-[min(100vw-2rem,24rem)] flex-col gap-2 rounded-2xl border p-3 shadow-lg backdrop-blur-sm"
role="region"
aria-label="Layout preview"
>
<p className="text-muted-foreground text-xs font-medium">Layout preview</p>
<div className={cn('grid gap-1', GRID_COLS[FEATURE_PREVIEW_VARIANTS.length] ?? 'grid-cols-3')}>
{FEATURE_PREVIEW_VARIANTS.map((option) => (
<Button
key={option}
type="button"
size="sm"
variant={variant === option ? 'default' : 'outline'}
className="h-8 text-xs"
aria-pressed={variant === option}
onClick={() => setVariant(option)}
>
{FEATURE_PREVIEW_LABELS[option]}
</Button>
))}
</div>
</div>
);
}
For 4–5 options with long labels, flex flex-wrap gap-1 is a good alternative to the grid.
Page wiring
import { Provider } from 'jotai'; // only needed if your state approach requires a provider
export function SomePage() {
return (
<Provider>
<FeatureName />
{/* other content */}
<FeatureNamePreviewToggle />
</Provider>
);
}
If the page already has a provider for other state, reuse it—do not nest providers unless isolating state.
UX and a11y notes
- Toggle is dev/preview only—remove before merge unless the team explicitly wants it.
- Wrap entire interactive regions (e.g. empty-state tiles) in tooltips, not just inner text.
- For non-button tooltip triggers, use
tabIndex={0}and a descriptivearia-labelon the wrapper. - Preserve semantic structure (
role="group",aria-labelon the stats region,sr-onlylabels where needed).
Phase 4 — Ship the winner (cleanup)
When the user picks a variant:
- Inline the winning variant into
feature-name.tsx(or keep onevariants/file only if large). - Keep
feature-name-data.tsif it still separates data from UI. - Delete:
feature-name-preview.state.ts,feature-name-preview-toggle.tsx, unusedvariants/*,feature-name-shared.tsxif no longer needed. - Remove toggle import from page/layout.
- Flatten folder if only
feature-name.tsx+ data file remain (match repo colocation conventions). - Run typecheck; grep for orphaned imports (
PreviewToggle,preview-variant).
Do not leave persisted preview state or floating toggles in production unless requested.
Checklist
Scaffold
- Shared data hook with stable item type
- 2–5 variant components, meaningfully different
- Preview state + labels + versioned storage key
- Router component + variant map
- Floating toggle on page, wired to whatever provider the state approach needs
Ship
- Winner merged into main component
- Preview/toggle/dead variants removed
- Page imports cleaned up
- Typecheck passes
Additional resources
- File templates and naming table: reference.md
