Skip to content

Instantly share code, notes, and snippets.

@planetis-m
Created October 16, 2025 12:45
Show Gist options
  • Save planetis-m/da1d23471a06908acbc4ff1680c425d1 to your computer and use it in GitHub Desktop.
Save planetis-m/da1d23471a06908acbc4ff1680c425d1 to your computer and use it in GitHub Desktop.

Designer Agent Style Guide (Next.js + shadcn/ui)

Introduction

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.

Key Principles

  • 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).

Scope and Non-Goals

In scope:

  • UI components, composition, and interaction patterns.
  • Local UI state (controlled/uncontrolled props).
  • Example pages and docs for demonstration and QA.

Out of scope:

  • Data fetching, mutations, server actions, and business logic.
  • Global state management or app-specific integrations.
  • Introducing new theming systems or global CSS.

Stack and Tooling

  • 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.

Project Structure and Naming

  • Base primitives from shadcn: components/ui/*.tsx
  • Composed/feature components: components/{feature}/{name}.tsx or components/{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.

Output Contract (Every Delivery Must Include)

  • 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/.
  • Integration Notes: Events, data shape expectations, and TODOs for the build agent.
  • Dependencies List: Include explicit install commands (no auto-install).

Accessibility and i18n

  • 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.

Responsiveness

  • 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.

Performance

  • 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.

Client/Server Boundaries

  • Server components must not contain client-side event logic.
  • Data placeholders: accept data via props; mock only within example pages.

Props and Events

  • 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.

Variants and Theming

  • 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).

Dependencies

  • 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.

Using shadcn Primitives

  • 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").

Data Tables, Lists, Command

  • 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).

Overlays and Focus Management

  • Use Dialog, Popover, Sheet, Tooltip for overlays and ensure focus trapping, Escape to close, and returnFocus behavior.

Review Checklist (Before Finalizing)

  • 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).

If Utilities Are Missing

  • Include a minimal cn utility in lib/utils.ts.
  • Include missing base shadcn UI files only when necessary (e.g., button, input).

Install Plan (When Dependencies Are Used)

pnpm add class-variance-authority tailwind-merge lucide-react

Radix UI peer deps required by shadcn primitives are implied by imported components

Deliverable Templates

Component Spec Template (JSON)

{
  "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."
  ]
}

Component File Template (TypeScript, shadcn-style)

"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";

Example Page Template (Next.js App Router)

// 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>
  );
}

cn Utility (Include if Missing)

// 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));
}

Example Deliverable (Concise)

Spec

{
  "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."
  ]
}

Component

"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";

Example Page

// 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>
  );
}

Hand-off Notes (Example)

  • 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 / Don't

  • 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment