@aircall/ds
v0.26.0
Published
Aircall Design System - Modern UI Component Library
Readme
@aircall/ds - Aircall Design System
Modern React component library built with TailwindCSS 4, Base UI primitives, and TypeScript.
Tech Stack
- React 19 - Latest React with compiler optimizations
- TailwindCSS 4 - Utility-first CSS with OKLch color space
- Base UI - Unstyled, accessible component primitives (
@base-ui/react) - TypeScript 5.7 - Type-safe development
- Class Variance Authority (CVA) - Variant-based component styling
- Storybook 10 - Interactive component documentation
For AI agents / coding assistants
If you are an AI assistant generating code that uses @aircall/ds, read
AGENTS.consumer.md (shipped in this package) for the rules the
TypeScript types can't express — Base UI conventions (use render, not asChild), the
required @aircall/ds/globals.css import, icon imports from @aircall/react-icons, and
dark-mode setup. The shipped .d.ts files are the source of truth for prop names and
values (variants are typed as literal unions) — do not invent props.
Migrating off @aircall/tractor? Turn on the AI migration skills
This package ships TanStack Intent migration skills in
its skills/ directory (published in the npm tarball) — version-matched, agent-loadable
guidance for moving an app off @aircall/tractor / @aircall/icons (and @dashboard/library
→ @aircall/blocks) to @aircall/ds. Enable them in your repo (one-time):
# 1. install / upgrade — @aircall/react-icons MUST be >= 0.4.0 (the ds bundle imports flags from it)
pnpm add @aircall/ds@latest @aircall/blocks@latest @aircall/react-icons@latest
# 2. let your AI agent discover the skills — writes a discovery block into CLAUDE.md / AGENTS.md
npx @tanstack/intent install
# 3. allowlist the packages in package.json, then verify discovery:
# { "intent": { "skills": ["@aircall/ds", "@aircall/blocks"] } }
npx @tanstack/intent list # should list @aircall/ds and @aircall/blocksYour agent then loads guidance on demand, starting with setup:
npx @tanstack/intent load "@aircall/ds#aircall-ds/setup"
# then per file: aircall-ds/migrate-tractor · aircall-ds/migrate-icons · aircall-blocks/migrate-dashboardEach migrate skill carries an end-to-end playbook (setup → inventory → migrate → verify) and
requires setup (providers incl. DsI18nProvider, the globals.css import, and the jsdom
test shims). Keep @aircall/tractor installed until the last Tractor import is gone —
migration is progressive (cohabitation).
Installation
For Monorepo Apps (Hydra Workspace)
If you're developing within the Hydra monorepo (e.g., apps/aw-web, apps/hubspot-cti):
# In your app's package.json
{
"dependencies": {
"@aircall/ds": "workspace:*"
}
}For External Apps
If you're using the design system in an external project or consuming the published NPM package:
pnpm add @aircall/dsRequired peer dependencies:
pnpm add react react-dom @aircall/react-icons @aircall/numbersOptional peer dependencies (install only if you use these components):
| Component | Packages to install |
|-----------|-------------------|
| Calendar | react-day-picker, date-fns |
| DataTable | @tanstack/react-table |
# If using Calendar
pnpm add react-day-picker date-fns
# If using DataTable
pnpm add @tanstack/react-tableThe package is published with its module graph preserved (
tsdownunbundle) and is markedsideEffects: ["**/*.css"], so these peers are only pulled into a consumer's bundle whenCalendar/DataTableare actually imported. Importing other components (e.g.Button) tree-shakes them away — no install required.
Usage
Import Components
Components work the same way in both monorepo and external apps:
import { Button } from '@aircall/ds';
function App() {
return (
<Button variant="default" color="primary" size="default">
Click me
</Button>
);
}Import CSS Styles
There is one stylesheet, @aircall/ds/globals.css, but it resolves to a different artifact depending on how you consume the package. (There is no separate styles.css export.)
Monorepo Apps (workspace:*)
The package's exports point at source, so @aircall/ds/globals.css is the un-compiled Tailwind source (@import "tailwindcss", @theme, …). Your app's Tailwind v4 build compiles it, and @source scans your own files for class names:
/* your app's entry CSS — DS only */
@import '@aircall/ds/globals.css';
@source "your_own_source_file";If you also use @aircall/blocks, import only blocks globals — its source already imports DS globals, so postcss-import deduplication means DS is compiled once:
/* your app's entry CSS — DS + blocks */
@import '@aircall/blocks/globals.css';
@source "your_own_source_file";External Apps (published npm package)
The published tarball rewrites exports to dist, so @aircall/ds/globals.css is a precompiled Tailwind v4 bundle — Preflight, tokens, and every DS utility class already generated.
DS only, no custom Tailwind classes:
// main.tsx
import '@aircall/ds/globals.css';DS + @aircall/blocks: import only blocks globals — it already bundles all of DS (Preflight, theme tokens, all DS utilities). Importing both duplicates the entire DS compiled output:
// main.tsx
import '@aircall/blocks/globals.css'; // already includes @aircall/ds/globals.cssAuthoring your own Tailwind utility classes alongside DS or blocks — use the split import to activate Tailwind for @source scanning without adding a second Preflight:
/* style.css */
/* Required: locks layer order so @layer utilities beats @layer base.
Without this, Preflight's border-width: 0 / display: block beat utility classes. */
@layer theme, base, components, utilities;
/* Split import — no Preflight. The .css extension is required;
bare specifiers (tailwindcss/theme) silently produce no output in Rsbuild/webpack. */
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
@import '@aircall/ds/globals.css'; /* DS only */
/* OR: @import '@aircall/blocks/globals.css'; for DS + blocks (don't import both) */
@source "./src/**/*.{ts,tsx}"; /* scan YOUR code, not node_modules */Module-federation consumer (your app is a remote inside a host that already loads DS globals — e.g. dashboard-v4): do NOT import DS or blocks globals. The host's Preflight covers the whole document; a duplicate from your remote's injected <style> tag will break DS component borders.
/* style.css */
@layer theme, base, components, utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
@source "./**/*.{ts,tsx}";Dark Mode
Dark mode is controlled via the data-theme attribute:
// Light mode (default)
<html>
// Dark mode
<html data-theme="dark">The design system uses CSS variables that automatically adjust based on this attribute.
Components
Button
Versatile button component with multiple variants and sizes, built on the Base UI
button primitive (so it also accepts Base UI Button props such as disabled).
Props:
variant: 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link' (default:'default')size: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg' (default:'default')block: boolean — full width (default:false)render: ReactElement — render Button as a different element (e.g.<a>). Replaces the formerasChild/ Slot pattern.
Icons are passed as children. Import them from
@aircall/react-icons(neverlucide-reactdirectly).
Examples:
import { Button } from '@aircall/ds';
import { Phone, Mail } from '@aircall/react-icons';
// Primary button (default variant)
<Button>Primary</Button>
// Secondary / outline / ghost
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
// Icon-only button — use an `icon*` size
<Button variant="outline" size="icon-sm">
<Phone />
</Button>
// Button with a leading icon
<Button>
<Mail />
Send Email
</Button>
// Full-width button
<Button block>Full Width</Button>
// Destructive button
<Button variant="destructive">Delete</Button>
// Render as a link — use the `render` prop (not `asChild`)
<Button render={<a href="/profile" />}>Go to Profile</Button>Storybook
Interactive component documentation with live examples, controls, and accessibility testing.
Quick Start
cd packages/ds
pnpm sb:devView at: http://localhost:6008
Features
- 🎨 Interactive component playground with live editing
- 🌓 Light/dark mode testing via toolbar switcher
- ♿ Accessibility testing with a11y addon
- 📱 Responsive viewport testing
- 🎭 Pseudo-state simulation (hover, focus, active)
- 📐 Figma design integration
- 📚 Auto-generated documentation from TypeScript types
Building Storybook
pnpm sb:build # plain build — no recipe registry
pnpm sb:build:deploy # full build including the recipe registry (what CI deploys)Static site output: storybook-static/. See Recipes (shadcn Registry) below for when to use each.
Development
Adding Components
This package uses shadcn/ui patterns for component development:
# Add a new component from shadcn (wraps `pnpx shadcn@latest add`)
pnpm shadcn:add <component-name>File Structure
src/
├── components/ # React components, one flat file per component (published to npm)
│ ├── button.tsx
│ ├── dialog.tsx
│ └── …
├── stories/ # Storybook stories (PascalCase, e.g. Button.stories.tsx)
├── styles/ # Global styles
│ ├── globals.css # TailwindCSS + theme variables
│ └── brand.css # Aircall brand colors
├── lib/ # Utilities
│ └── utils.ts # cn() helper for class merging
├── hooks/ # Custom React hooks
├── recipes/ # shadcn registry recipes (copy-paste patterns, NOT published to npm)
│ ├── combobox-searchable-select.tsx
│ ├── combobox-dropdown-search.tsx
│ └── combobox-multi-select.tsx
└── fonts/ # Fellix Aircall font filesStyling System
Components use Class Variance Authority (CVA) for variant-based styling:
const buttonVariants = cva('base-classes', {
variants: {
variant: {
default: 'variant-specific-classes',
outline: 'outline-specific-classes'
}
},
defaultVariants: {
variant: 'default'
}
});This provides:
- Type-safe variant props
- Automatic TypeScript inference
- Composable class generation
- TailwindCSS class merging via
tailwind-merge
Theme System
The design system uses CSS variables with OKLch color space for better color perception:
/* Light mode (default) */
:root {
--primary: oklch(52.42% 0.201 192.01);
}
/* Dark mode */
[data-theme='dark'] {
--primary: oklch(78.09% 0.128 192.01);
}Colors automatically adapt when data-theme="dark" is set on the root element.
Recipes (shadcn Registry)
Recipes are pre-built, copy-paste component patterns built on top of @aircall/ds primitives. They are distributed via the shadcn registry — consumers run shadcn add to install a recipe into their project, and they own the code afterwards. They can customize it freely.
Note: in the shadcn registry schema these items are typed
registry:block— that's a shadcn enum value, not our terminology. Internally we call them "recipes".
What is the Registry?
The registry is a set of JSON files that describe each recipe — its name, description, npm dependencies, and full source code. These JSON files are generated by shadcn build and served as static files alongside the DS Storybook.
How it works:
- Recipe source files live in
src/recipes/using relative imports (../components/combobox) pnpm run registry:buildcopies the recipes, rewrites imports to@aircall/ds, runsshadcn build, and outputs JSON files topublic/r/- Storybook serves
public/as static files viastaticDirsconfig - A consumer runs
shadcn add <url>— the CLI fetches the JSON, extracts the source code, and writes it into their project with@aircall/dsimports
Local Development
Run pnpm run registry:build before starting Storybook to generate the JSON files. Then start Storybook:
pnpm run registry:build
pnpm run sb:devThe recipe JSON files are served at http://localhost:6008/r/<name>.json. You can test the full consumer flow locally:
pnpm dlx shadcn@latest add http://localhost:6008/r/combobox-searchable-select.jsonProduction (Published Storybook)
When the DS Storybook is deployed to ds.aircall.io, the same JSON files are served at the public URL. Consumers install recipes with:
pnpm dlx shadcn@latest add https://ds.aircall.io/r/combobox-searchable-select.jsonThe deployment CI job runs pnpm sb:build:ds at the repo root, which maps to sb:build:deploy inside this package. That script runs registry:build first, then storybook build — Storybook's staticDirs: ['../public'] config picks up public/r/ and copies it into storybook-static/r/ so the JSON files ship alongside the deployed Storybook. sb:build on its own does NOT bundle the registry — use sb:build:deploy (or pnpm sb:build:ds at the root) whenever you need the registry included.
Available Recipes
| Recipe | Description |
|---|---|
| combobox-searchable-select | Single-select combobox — input acts as trigger and search filter |
| combobox-dropdown-search | Button-triggered combobox with search inside dropdown |
| combobox-multi-select | Multi-select with inline chips and type-ahead filtering |
Adding a New Recipe
- Create
src/recipes/<name>.tsx— import primitives from../components/ - Add an entry to
registry.jsonat the package root - Add a story in
src/stories/that imports from the recipe - Run
pnpm run registry:buildto generate JSON inpublic/r/ - Verify at
http://localhost:6008/r/<name>.jsonafter starting Storybook
Scripts
# Development
pnpm sb:dev # Start Storybook development server
pnpm lint # Run ESLint
# Building for Distribution
pnpm build:package # Build both JS and CSS for publishing
pnpm build:js # Build JavaScript bundle only
pnpm build:css # Build pre-compiled CSS only
# Registry
pnpm registry:build # Generate recipe JSON files in public/r/
# (rewrites relative imports to @aircall/ds)
# Storybook — three distinct builds
pnpm sb:build # Plain Storybook build. NO registry JSON.
pnpm sb:build:chromatic # Storybook build for Chromatic (--test --stats-json). NO registry JSON.
pnpm sb:build:deploy # Full deployment build: runs registry:build, builds Storybook,
# and copies public/r into storybook-static/r/.
# This is what CI runs (via `pnpm sb:build:ds` at the repo root).
# Testing Package Locally
pnpm pack # Create tarball for local testing
# Component Management
pnpm shadcn:add <component> # Add new shadcn component
# Maintenance
pnpm clean # Remove build artifacts