Imported from hanzoai/insights (
packages/quill/AGENTS.md). Install upstream withnpx skills add hanzoai/insights --skill quill. Copyright stays with the author.
Quill Primitives — Agent Guide
What is Quill?
Quill is Insights's design system built on React, TypeScript, Base UI, and Tailwind v4. It follows a layered architecture: tokens > primitives > components > blocks.
This guide covers the primitives layer — the foundational UI components that everything else builds on.
Package structure
packages/quill/
├── packages/
│ ├── tokens/ @hanzo/quill-tokens — Design tokens (JS + generated CSS)
│ ├── primitives/ @hanzo/quill-primitives — Base UI components (this guide)
│ ├── components/ @hanzo/quill-components — Composed primitives
│ └── blocks/ @hanzo/quill-blocks — Product-level patterns
└── apps/
└── storybook/ — Component documentation
Core patterns
1. Base UI wrapping
Every interactive primitive wraps a @base-ui/react component.
Base UI provides behavior, accessibility, and state — we add styling via Tailwind.
import { Button as ButtonPrimitive } from '@base-ui/react/button'
const Button = React.forwardRef<HTMLButtonElement, Props>(({ className, variant, size, ...props }, ref) => (
<ButtonPrimitive
ref={ref}
data-slot="button"
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
))
2. CVA for variants
All variant styling uses class-variance-authority.
Define variants as a cva() call, export the variants type for consumers.
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva('inline-flex items-center ...', {
variants: {
variant: {
default: 'bg-secondary ...',
outline: 'border-foreground/10 ...',
destructive: 'bg-destructive ...',
},
size: {
default: 'h-7 px-2 text-xs',
sm: 'h-6 px-2 text-xs',
xs: 'h-5 px-2 text-[0.625rem]',
},
},
defaultVariants: { variant: 'default', size: 'default' },
})
3. cn() utility
Always use cn() from ./lib/utils for class merging.
It combines clsx (conditional classes) + tailwind-merge (conflict resolution).
import { cn } from './lib/utils'
className={cn(baseClasses, conditional && 'extra-class', className)}
4. data-slot attributes
Every component root sets data-slot="component-name".
This enables parent-based styling via has-data-[slot=...] and *:data-[slot=...].
5. forwardRef for render prop targets
Any component used as a render={<Component />} target in Base UI must use React.forwardRef.
React 18 does not forward refs to function components — Base UI's render prop passes refs.
Components that need forwardRef:
Button,Input,Chip,ChipCloseInputGroup,InputGroupInput,InputGroupButtonComboboxTrigger- Any component passed to Base UI's
renderprop
6. Composition hierarchy
Button
├── Chip (default size=sm, variant=outline)
├── ChipClose (size=icon-xs)
├── InputGroupButton
├── TabsTrigger (render prop)
├── CollapsibleTrigger (render prop)
├── SelectTrigger (render prop)
├── DropdownMenuItem (render prop)
└── ToggleGroupItem (render prop)
Input
└── InputGroupInput (border-0, bg-transparent)
InputGroup
├── ComboboxInput (with anchor context)
└── AutocompleteInput (with anchor context)
Chip + ChipClose
└── ComboboxChip (via render prop)
ButtonGroup
└── ChipGroup (flex-wrap, gap-0)
Component conventions
Creating a new primitive
- Wrap the Base UI component
- Add
data-slot="your-component"to the root - Use
forwardRefif the component may be used inrenderprops - Define variants with CVA if the component has visual variants
- Accept
classNameprop and merge withcn() - Export from the component file (barrel export is in
index.ts) - Update the consumer guide in the same PR —
packages/primitives/AGENTS.md(catalog row, "Choosing a component" cluster, composition pattern). Same rule applies topackages/components/AGENTS.mdandpackages/charts/AGENTS.mdfor their packages. A lint-staged warning fires if quill sources change without an AGENTS.md update
Styling rules
- Use Tailwind utility classes exclusively — no inline styles, no CSS modules
- Use semantic color tokens:
bg-primary,text-foreground,border-input— never raw colors - Components that accept icon children own their icon sizing —
flex-shrink: 0plus asvg:not([class*='size-'])size rule in the component's.cssfile (seebutton.css), so consumers drop in bare lucide icons withoutsize-*classes; the:notguard keeps explicitsize-*as a deliberate escape hatch - Use
group/namefor scoped parent selectors (e.g.,group/tabs,group/input-group) - Use
data-[variant=...]selectors for variant-aware child styling - Focus states:
focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 - Invalid states:
aria-invalid:bg-destructive/50 aria-invalid:border-destructive-foreground/30 - Disabled states:
disabled:pointer-events-none disabled:opacity-50 - Dark mode: use
dark:prefix only when light/dark need different values beyond token swapping
Base UI render prop pattern
When composing primitives inside Base UI components, use the render prop:
// Good — Base UI manages behavior, our component provides styling
<ComboboxPrimitive.Chip render={<Chip />}>
{children}
</ComboboxPrimitive.Chip>
// Good — nesting render props
<InputGroupButton render={<ComboboxTrigger />} />
Context for cross-sibling communication
When siblings need shared state (e.g., ComboboxInput and ComboboxContent sharing an anchor ref),
use React context at their common parent:
const AnchorContext = React.createContext<React.RefObject<HTMLDivElement> | null>(null)
function Parent({ children }) {
const ref = React.useRef<HTMLDivElement>(null!)
return (
<AnchorContext.Provider value={ref}>
<Primitive.Root>{children}</Primitive.Root>
</AnchorContext.Provider>
)
}
Storybook
- Stories live next to components:
component.stories.tsx - Use
Meta<typeof Component>type annotation (notsatisfies Meta<typeof Component>— avoids strict args issues with Base UI props) - Each story should demonstrate a distinct use case
- Run storybook:
pnpm --filter storybook dev
Commands
- Dev storybook:
pnpm --filter storybook dev - Build primitives:
pnpm --filter @hanzo/quill-primitives build - Build tokens:
pnpm --filter @hanzo/quill-tokens build - Format: use prettier via the quill root config
Key dependencies
| Package | Purpose |
|---|---|
@base-ui/react |
Headless UI primitives (behavior + accessibility) |
class-variance-authority |
Type-safe variant class maps |
clsx + tailwind-merge |
Class merging without conflicts |
lucide-react |
Icons |
react-resizable-panels |
Resizable panel layout |
Token system
Components never import color values directly.
They use Tailwind classes (bg-primary, text-foreground) which reference CSS custom properties (--primary, --foreground).
The token flow:
@hanzo/quill-tokensdefines colors as JS objects- Build script generates
color-system.css(actual values) andstyles.css(@thememappings) - Primitives ship only the
@thememappings — the consuming app providescolor-system.css
This means primitives are theme-agnostic — swap color-system.css for a different theme.