Beagle shadcn-ui
shadcn/ui component patterns with Radix primitives and Tailwind styling. Use when building UI components, using CVA variants, implementing compound components, or styling with data-slot attributes. Triggers on shadcn, cva, cn(), data-slot, Radix, Button, Card, Dialog, VariantProps.
install
source · Clone the upstream repo
git clone https://github.com/existential-birds/beagle
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/existential-birds/beagle "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/beagle-react/skills/shadcn-ui" ~/.claude/skills/existential-birds-beagle-shadcn-ui && rm -rf "$T"
manifest:
plugins/beagle-react/skills/shadcn-ui/SKILL.mdsource content
shadcn/ui Component Development
Contents
- CLI Commands - Installing and adding components
- Quick Reference - cn(), basic CVA pattern
- Component Anatomy - Props typing, asChild, data-slot
- Component Patterns - Compound components
- Styling Techniques - CVA variants, modern CSS selectors, accessibility states
- Decision Tables - When to use CVA, compound components, asChild, Context
- Common Patterns - Form elements, dialogs, sidebars
- Reference Files - Full implementations and advanced patterns
CLI Commands
Initialize shadcn/ui
npx shadcn@latest init
This creates a
components.json configuration file and sets up:
- Tailwind CSS configuration
- CSS variables for theming
- cn() utility function
- Required dependencies
Add Components
# Add a single component npx shadcn@latest add button # Add multiple components npx shadcn@latest add button card dialog # Add all available components npx shadcn@latest add --all
Important: The package name changed in 2024:
- Old (deprecated):
npx shadcn-ui@latest add - Current:
npx shadcn@latest add
Common Options
- Skip confirmation prompt-y, --yes
- Overwrite existing files-o, --overwrite
- Set working directory-c, --cwd <cwd>
- Use src directory structure--src-dir
Quick Reference
cn() Utility
import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
Basic CVA Pattern
import { cva, type VariantProps } from "class-variance-authority" const buttonVariants = cva( "base-classes-applied-to-all-variants", { variants: { variant: { default: "bg-primary text-primary-foreground", outline: "border bg-background", }, size: { sm: "h-8 px-3", lg: "h-10 px-6", }, }, defaultVariants: { variant: "default", size: "sm", }, } ) function Button({ variant, size, className, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) { return ( <button className={cn(buttonVariants({ variant, size }), className)} {...props} /> ) } export { Button, buttonVariants }
Component Anatomy
Props Typing Patterns
// HTML elements function Component({ className, ...props }: React.ComponentProps<"div">) { return <div className={cn("base-classes", className)} {...props} /> } // Radix primitives function Component({ className, ...props }: React.ComponentProps<typeof RadixPrimitive.Root>) { return <RadixPrimitive.Root className={cn("base-classes", className)} {...props} /> } // With CVA variants function Component({ variant, size, className, ...props }: React.ComponentProps<"button"> & VariantProps<typeof variants>) { return <button className={cn(variants({ variant, size }), className)} {...props} /> }
asChild Pattern
Enables polymorphic rendering via
@radix-ui/react-slot:
import { Slot } from "@radix-ui/react-slot" function Button({ asChild = false, className, variant, size, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" className={cn(buttonVariants({ variant, size }), className)} {...props} /> ) }
Usage:
<Button>Click me</Button> // Renders <button> <Button asChild><a href="/home">Home</a></Button> // Renders <a> with button styling <Button asChild><Link href="/dash">Dash</Link></Button> // Works with Next.js Link
data-slot Attributes
Every component includes
data-slot for CSS targeting:
function Card({ ...props }) { return <div data-slot="card" {...props} /> } function CardHeader({ ...props }) { return <div data-slot="card-header" {...props} /> }
CSS/Tailwind targeting:
[data-slot="button"] { /* styles */ } [data-slot="card"] [data-slot="button"] { /* nested targeting */ }
<div className="[&_[data-slot=button]]:shadow-lg"> <Button>Automatically styled</Button> </div>
Conditional layouts with has():
<div data-slot="card-header" className={cn( "grid gap-2", "has-data-[slot=card-action]:grid-cols-[1fr_auto]" )} />
Component Patterns
Compound Components
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } function Card({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card" className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} {...props} /> ) } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="card-header" className={cn("grid gap-2 px-6", className)} {...props} /> } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} /> }
Styling Techniques
CVA Variants
Multiple dimensions:
const buttonVariants = cva("base-classes", { variants: { variant: { default: "bg-primary text-primary-foreground", destructive: "bg-destructive text-white", outline: "border bg-background", ghost: "hover:bg-accent", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", sm: "h-8 px-3", lg: "h-10 px-6", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default" }, })
Compound variants:
compoundVariants: [ { variant: "outline", size: "lg", class: "border-2" }, ]
Type extraction:
type ButtonVariants = VariantProps<typeof buttonVariants> // Result: { variant?: "default" | "outline" | ..., size?: "sm" | "lg" | ... }
Modern CSS Selectors in Tailwind
has() selector:
<button className="px-4 has-[>svg]:px-3"> // Adjusts padding when contains icon <div className="has-data-[slot=action]:grid-cols-[1fr_auto]"> // Conditional layout
Group/peer selectors:
<div className="group" data-state="collapsed"> <div className="group-data-[state=collapsed]:hidden">Hidden when collapsed</div> </div> <button className="peer/menu" data-active="true">Menu</button> <div className="peer-data-[active=true]/menu:text-accent">Styled when sibling active</div>
Container queries:
<div className="@container/card"> <div className="@md:flex-row">Responds to container width</div> </div>
Accessibility States
className={cn( // Focus "outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", // Invalid "aria-invalid:border-destructive aria-invalid:ring-destructive/20", // Disabled "disabled:pointer-events-none disabled:opacity-50", )} <span className="sr-only">Close</span> // Screen reader only
Dark Mode
Semantic tokens adapt automatically:
className="bg-background text-foreground dark:bg-input/30 dark:hover:bg-input/50"
Tokens:
bg-background, text-foreground, bg-primary, text-primary-foreground, bg-card, text-card-foreground, border-input, text-muted-foreground
Decision Tables
When to Use CVA
| Scenario | Use CVA | Alternative |
|---|---|---|
| Multiple visual variants (primary, outline, ghost) | Yes | Plain className |
| Size variations (sm, md, lg) | Yes | Plain className |
| Compound conditions (outline + large = thick border) | Yes | Conditional cn() |
| One-off custom styling | No | className prop |
| Dynamic colors from props | No | Inline styles or CSS variables |
When to Use Compound Components
| Scenario | Use Compound | Alternative |
|---|---|---|
| Complex UI with multiple semantic parts | Yes | Single component with many props |
| Optional sections (header, footer) | Yes | Boolean show/hide props |
| Different styling for each part | Yes | CSS selectors |
| Shared state between parts | Yes + Context | Props drilling |
| Simple wrapper with children | No | Single component |
When to Use asChild
| Scenario | Use asChild | Alternative |
|---|---|---|
| Component should work as link or button | Yes | Duplicate component |
| Need button styles on custom element | Yes | Export variant styles |
| Integration with routing libraries | Yes | Wrapper components |
| Always renders same element | No | Standard component |
When to Use Context
| Scenario | Use Context | Alternative |
|---|---|---|
| Deep prop drilling (>3 levels) | Yes | Props |
| State shared by many siblings | Yes | Lift state up |
| Plugin/extension architecture | Yes | Props |
| Simple parent-child communication | No | Props |
Common Patterns
Form Input
function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( <input type={type} data-slot="input" className={cn( "h-9 w-full rounded-md border px-3 py-1", "outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:border-destructive aria-invalid:ring-destructive/20", "disabled:cursor-not-allowed disabled:opacity-50", "placeholder:text-muted-foreground dark:bg-input/30", className )} {...props} /> ) }
Dialog Content
function DialogContent({ children, showCloseButton = true, ...props }) { return ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Content data-slot="dialog-content" className={cn( "fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] w-full max-w-lg", "bg-background border rounded-lg p-6 shadow-lg", "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", )} {...props} > {children} {showCloseButton && ( <DialogPrimitive.Close className="absolute top-4 right-4"> <XIcon /><span className="sr-only">Close</span> </DialogPrimitive.Close> )} </DialogPrimitive.Content> </DialogPortal> ) }
Sidebar with Context
function SidebarProvider({ defaultOpen = true, children }) { const isMobile = useIsMobile() const [open, setOpen] = React.useState(defaultOpen) React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "b" && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen(o => !o) } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, []) const contextValue = React.useMemo( () => ({ state: open ? "expanded" : "collapsed", open, setOpen, isMobile }), [open, setOpen, isMobile] ) return ( <SidebarContext.Provider value={contextValue}> <div data-slot="sidebar-wrapper" style={{ "--sidebar-width": "16rem", "--sidebar-width-icon": "3rem" } as React.CSSProperties} > {children} </div> </SidebarContext.Provider> ) }
Reference Files
For comprehensive examples and advanced patterns:
- components.md - Full implementations: Button, Card, Badge, Input, Label, Textarea, Dialog
- cva.md - CVA patterns: compound variants, responsive variants, type extraction
- patterns.md - Architectural patterns: compound components, asChild, controlled state, Context, data-slot, has() selectors