DC Logo
Dylan Clemann

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

  1. Move fetching, context reads, and formatting into feature-name-data.ts.
  2. Export a stable props shape variants consume, e.g. FeatureItem[] with { id, label, value, isEmpty }.
  3. 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

  1. Add one file per option under variants/.
  2. Each exports a single component: FeatureNameVariantA({ items }: { items: FeatureItem[] }).
  3. Share markup via feature-name-shared.tsx when variants differ only in wrapper/layout (icons, tooltips, empty states).
  4. 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 persist middleware.
  • Already on Redux? A slice + a localStorage-syncing subscriber, or redux-persist.
  • No global state library? A small React Context provider wrapping useState + localStorage. A bare useState hook 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 descriptive aria-label on the wrapper.
  • Preserve semantic structure (role="group", aria-label on the stats region, sr-only labels where needed).

Phase 4 — Ship the winner (cleanup)

When the user picks a variant:

  1. Inline the winning variant into feature-name.tsx (or keep one variants/ file only if large).
  2. Keep feature-name-data.ts if it still separates data from UI.
  3. Delete: feature-name-preview.state.ts, feature-name-preview-toggle.tsx, unused variants/*, feature-name-shared.tsx if no longer needed.
  4. Remove toggle import from page/layout.
  5. Flatten folder if only feature-name.tsx + data file remain (match repo colocation conventions).
  6. 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