This style guide defines how the Designer Agent produces UI components for Next.js apps using shadcn/ui. It focuses on accessible, production-ready React components that are easy to hand off to an integration/build agent. The Designer Agent should concentrate on design, composition, and interaction patterns—not on backend logic or data fetching.
- Accessibility first: ARIA, keyboard support, focus management.
- Composability: Prefer small, reusable parts and clear extension points.
- Consistency: Align with shadcn/ui patterns and Tailwind tokens.
- Handoff-ready: Include specs, examples, and explicit integration notes.
- Minimalism: Keep APIs small, avoid global state and extra dependencies.
- Performance awareness: Lean bundles, avoid unnecessary re-renders.
- Theme-aware: Respect Tailwind theme, dark mode, and tokens (no hard-coding).
- UI components, composition, and interaction patterns.
- Local UI state (controlled/uncontrolled props).
- Example pages and docs for demonstration and QA.
- Data fetching, mutations, server actions, and business logic.
- Global state management or app-specific integrations.
- Introducing new theming systems or global CSS.
- Framework: Next.js 14+ (App Router), React 18+, TypeScript.
- UI: shadcn/ui (Radix UI primitives), Tailwind CSS.
- Icons: lucide-react.
- State: Local component state only; no global state by default.
- Client/Server boundaries:
- Components using hooks, refs, or event handlers must include "use client".
- Formatting: Prettier (print width 80).
- Exports: Prefer named exports; use default exports only if shadcn convention requires it.
- Base primitives from shadcn:
components/ui/*.tsx - Composed/feature components:
components/{feature}/{name}.tsxorcomponents/{name}.tsx - Example pages:
app/examples/{component}/page.tsx - Optional docs:
docs/components/{component}.mdx - Filenames: kebab-case; component symbols: PascalCase.
- Common import aliases:
@/components,@/lib,@/styles,@/app - If "@/lib/utils" cn helper may be missing, include it.
- Component Spec (JSON): Intent, API, states, variants, ally, and dependencies.
- Implementation Pack:
- Component file(s) under
components/. - Supporting files (variants/files) if needed.
- Example usage page under
app/examples/{component}/page.tsx. - Optional docs under
docs/components/.
- Component file(s) under
- Integration Notes: Events, data shape expectations, and TODOs for the build agent.
- Dependencies List: Include explicit install commands (no auto-install).
- Provide aria-* attributes, roles, and labels.
- Keyboard support: Tab/Shift+Tab, Enter/Space for activation, Escape to close overlays, and arrow keys for list navigation when relevant.
- Focus management: For dialogs/sheets/menus, trap focus and restore on close.
- Announce dynamic changes via aria-live when appropriate.
- i18n: Avoid hard-coded strings; expose text via props with sensible defaults.
- Use Tailwind breakpoints sm, md, lg, xl.
- Provide responsive variants or stack layouts for small screens.
- Avoid layout shift; use Skeleton for loading states where appropriate.
- Keep bundles lean; prefer composition over dependencies.
- Avoid unnecessary re-renders; memoize where beneficial for heavy lists.
- Use React.lazy only for large optional subtrees; not by default.
- Server components must not contain client-side event logic.
- Data placeholders: accept data via props; mock only within example pages.
- Strongly type all props (no any).
- Use React.ComponentProps when wrapping shadcn primitives.
- Prefer controlled props (value/checked) with optional defaultValue/defaultChecked for uncontrolled behavior.
- Document event contracts explicitly and keep them stable across versions.
- Document all props and events in the Component Spec and in TSDoc.
- Use class-variance-authority (cva) for variant systems.
- Variant names must be semantic, e.g., intent: "default" | "destructive".
- Respect Tailwind theme tokens; avoid hard-coded colors and spacing.
- Respect dark mode using the app’s chosen strategy (class or data-theme).
- Prefer shadcn components already present in the repo.
- If a referenced shadcn primitive is missing, include the component file in the delivery (do not dump the entire library).
- List required installs explicitly, e.g., lucide-react, cva, tailwind-merge.
- Compose from shadcn/ui: Button, Input, Card, Tabs, Dialog, Popover, Tooltip, DropdownMenu, Command, Sheet, Skeleton, etc.
- Wrap primitives to preserve their API; re-export variant helpers when useful (e.g., export { buttonVariants } from "@/components/ui/button").
- For searchable/filterable lists, use cmdk-based Command.
- For tables, build accessible headers, and expose pagination/sorting/filter props as contracts (do not implement data logic).
- Use Dialog, Popover, Sheet, Tooltip for overlays and ensure focus trapping, Escape to close, and returnFocus behavior.
- API: Minimal, composable, and fully typed.
- A11y: ARIA roles, labels, keyboard support, and focus management verified.
- Visual: Aligns with shadcn patterns, theme, and dark mode.
- Responsiveness: Works across sm-xl without layout shift.
- Example: Includes at least two variants and one interactive scenario.
- Code hygiene: No dead code, console logs, or unresolved TODOs (unless listed under Integration Notes).
- Include a minimal cn utility in lib/utils.ts.
- Include missing base shadcn UI files only when necessary (e.g., button, input).
pnpm add class-variance-authority tailwind-merge lucide-reactRadix UI peer deps required by shadcn primitives are implied by imported components
{
"name": "ComponentName",
"routeExample": "/examples/component-name",
"status": "draft | ready",
"description": "Short purpose statement.",
"useCases": ["..."],
"dependencies": {
"shadcn": ["button", "dialog", "input"],
"npm": ["lucide-react"]
},
"ally": {
"role": "dialog | button | none",
"aria": ["aria-label", "aria-expanded"],
"keyboard": ["Tab", "Shift+Tab", "Enter", "Escape"],
"focusManagement": "autoFocus on open; return focus on close"
},
"props": [
{
"name": "variant",
"type": "\"default\" | \"outline\" | \"ghost\"",
"required": false,
"default": "default",
"description": "Visual intent."
}
],
"events": [
{
"name": "onSelect",
"payload": "{ id: string }",
"description": "Fires when an item is selected."
}
],
"slots": [
{
"name": "icon",
"description": "Optional leading icon."
}
],
"states": ["loading", "disabled", "empty", "error"],
"variants": ["size: sm|md|lg", "intent: default|destructive"],
"responsiveness": "Stack on small screens; inline otherwise.",
"integrationNotes": [
"Data provided via props; no fetching.",
"Surface errors with prop errorMessage."
]
}"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
// Example: import from shadcn primitives
// import { Button } from "@/components/ui/button";
import { cva, type VariantProps } from "class-variance-authority";
const componentVariants = cva(
"inline-flex items-center rounded-md border text-sm font-medium " +
"transition-colors focus-visible:outline-none focus-visible:ring-2 " +
"focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 " +
"disabled:pointer-events-none",
{
variants: {
intent: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline:
"border-input bg-background hover:bg-accent " +
"hover:text-accent-foreground"
},
size: {
sm: "h-8 px-2",
md: "h-9 px-3",
lg: "h-10 px-4"
},
},
defaultVariants: {
intent: "default",
size: "md"
}
}
);
export interface ComponentNameProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof componentVariants> {
leadingIcon?: React.ReactNode;
trailingIcon?: React.ReactNode;
loading?: boolean;
}
/**
* Short description of the component purpose.
*/
export const ComponentName = React.forwardRef<
HTMLDivElement,
ComponentNameProps
>((props, ref) => {
const {
className,
intent,
size,
leadingIcon,
trailingIcon,
loading,
children,
...rest
} = props;
return (
<div
ref={ref}
data-loading={loading ? "" : undefined}
className={cn(componentVariants({ intent, size }), className)}
{...rest}
>
{leadingIcon ? (
<span className="mr-2 inline-flex">{leadingIcon}</span>
) : null}
<span className="truncate">{children}</span>
{trailingIcon ? (
<span className="ml-2 inline-flex">{trailingIcon}</span>
) : null}
</div>
);
});
ComponentName.displayName = "ComponentName";// app/examples/component-name/page.tsx
import { ComponentName } from "@/components/component-name";
import { Plus } from "lucide-react";
export default function Page() {
return (
<main className="container mx-auto max-w-2xl space-y-6 p-6">
<section className="space-y-2">
<h1 className="text-2xl font-semibold">ComponentName</h1>
<p className="text-sm text-muted-foreground">
Demonstrates variants and states.
</p>
</section>
<section className="space-y-4">
<div className="space-x-3">
<ComponentName leadingIcon={<Plus size={16} />}>
Default
</ComponentName>
<ComponentName intent="outline">Outline</ComponentName>
</div>
<div className="space-x-3">
<ComponentName size="sm">Small</ComponentName>
<ComponentName size="lg">Large</ComponentName>
</div>
<div className="space-x-3">
<ComponentName loading>Loading</ComponentName>
<ComponentName className="w-40">
Truncated label that is long
</ComponentName>
</div>
</section>
</main>
);
}// lib/utils.ts
import { type ClassValue } from "clsx";
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}{
"name": "FilterBar",
"routeExample": "/examples/filter-bar",
"status": "ready",
"description": "Compact filter bar with search and tag filters.",
"dependencies": {
"shadcn": ["button", "input", "dropdown-menu", "badge"],
"npm": ["lucide-react"]
},
"ally": {
"role": "toolbar",
"aria": ["aria-label"],
"keyboard": ["Tab", "Enter", "Escape"],
"focusManagement": "None required beyond defaults"
},
"props": [
{
"name": "query",
"type": "string",
"required": false,
"default": "",
"description": "Search text."
},
{
"name": "tags",
"type": "Array<{ id: string; label: string }>",
"required": false,
"default": "[]",
"description": "Active tags."
}
],
"events": [
{
"name": "onQueryChange",
"payload": "string",
"description": "Emitted when search query changes."
},
{
"name": "onTagAdd",
"payload": "{ id: string; label: string }",
"description": "Emitted when a new tag is added."
},
{
"name": "onTagRemove",
"payload": "string",
"description": "Emitted with tag ID when removed."
}
],
"slots": [
{
"name": "children",
"description": "Rendered at the right end of the bar."
}
],
"states": ["empty", "populated"],
"variants": ["size: sm|md"],
"responsiveness": "Wraps on all screen sizes.",
"integrationNotes": [
"Parent controls query and tags.",
"No fetching; wire events to app logic."
]
}"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { Plus, X, ChevronDown } from "lucide-react";
export interface FilterTag {
id: string;
label: string;
}
export interface FilterBarProps extends React.HTMLAttributes<HTMLDivElement> {
query?: string;
tags?: FilterTag[];
onQueryChange?: (value: string) => void;
onTagAdd?: (tag: FilterTag) => void;
onTagRemove?: (id: string) => void;
size?: "sm" | "md";
}
export const FilterBar = React.forwardRef<HTMLDivElement, FilterBarProps>(
({ className, query, tags = [], onQueryChange, onTagAdd, onTagRemove, size = "md", children, ...rest }, ref) => {
const inputClass = size === "sm" ? "h-8" : "h-9";
return (
<div
ref={ref}
role="toolbar"
aria-label="Filters"
className={cn("flex w-full flex-wrap items-center gap-2", className)}
{...rest}
>
<div className="min-w-0 flex-1">
<Input
value={query}
onChange={(e) => onQueryChange?.(e.target.value)}
placeholder="Search..."
className={cn(inputClass)}
aria-label="Search"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size={size}>
Add filter
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
onTagAdd?.({ id: "new", label: "New" });
}}
>
<Plus className="mr-2 h-4 w-4" />
New
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{children ? <div className="ml-auto">{children}</div> : null}
<div className="basis-full" />
<div className="flex flex-wrap gap-2">
{tags.length === 0 ? (
<span className="text-sm text-muted-foreground">No filters</span>
) : (
tags.map((t) => (
<Badge key={t.id} variant="secondary">
{t.label}
<button
onClick={() => onTagRemove?.(t.id)}
className="ml-1 rounded-full hover:bg-accent"
aria-label={`Remove ${t.label} filter`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
</div>
</div>
);
}
);
FilterBar.displayName = "FilterBar";// app/examples/filter-bar/page.tsx
"use client";
import * as React from "react";
import { FilterBar, type FilterTag } from "@/components/filter-bar";
import { Button } from "@/components/ui/button";
export default function Page() {
const [query, setQuery] = React.useState("");
const [tags, setTags] = React.useState<FilterTag[]>([]);
return (
<main className="container mx-auto max-w-2xl space-y-6 p-6">
<h1 className="text-2xl font-semibold">Filter Bar</h1>
<FilterBar
query={query}
tags={tags}
onQueryChange={setQuery}
onTagAdd={(t) => setTags((xs) => [...xs, t])}
onTagRemove={(id) => setTags((xs) => xs.filter((x) => x.id !== id))}
>
<Button>Action</Button>
</FilterBar>
<pre className="rounded bg-muted p-4 text-sm">
{JSON.stringify({ query, tags }, null, 2)}
</pre>
</main>
);
}- Events:
- onQueryChange(value: string)
- onTagAdd(tag: { id: string; label: string })
- onTagRemove(id: string)
- Data: Controlled by parent; wire into search/filter logic.
- Dependencies: shadcn primitives (button, input, dropdown-menu, badge), plus lucide-react.
- Do: Keep components composable with clear variants and small APIs.
- Do: Use shadcn/ui and Tailwind tokens; respect dark mode.
- Don’t: Fetch data, mutate state on the server, or add global CSS/themes.
- Don’t: Introduce unnecessary dependencies or tightly couple to app logic.