@antfu/design
@antfu/design
A customizable, composable design system for devtools-style Vue apps: a UnoCSS preset (
presetAnthonyDesign), a set of Vue primitives, a ground-up design skill, and a color-contrast a11y check. Something in between a component library and shadcn.
Install
pnpm add @antfu/design unocss vue
Quick start
// uno.config.ts
import { presetAnthonyDesign } from '@antfu/design/unocss'
import transformerDirectives from '@unocss/transformer-directives'
import { defineConfig, presetIcons, presetWebFonts, presetWind4 } from 'unocss'
export default defineConfig({
presets: [
presetAnthonyDesign({ primary: '#49833E' }),
presetWind4(), // a base preset is required — bring your own
presetIcons(),
presetWebFonts({ fonts: { sans: 'DM Sans', mono: 'DM Mono' } }),
],
// Required — most shipped styles recolor overlays with token `--at-apply`
// directives; this transformer expands them (and lets you reuse tokens in CSS).
// (vue-afloat.css is the exception — it themes via vue-afloat's own
// `--vue-afloat-*` CSS variables and needs no transformer.)
transformers: [transformerDirectives()],
// The preset ships no z-index scale (stacking is the app's to own) and blocks
// plain `z-<number>`. Define the named layers the overlay components use here —
// these values are yours; tune them to fit your app's stack.
shortcuts: {
'z-nav': 'z-[30]',
'z-dropdown': 'z-[40]',
'z-tooltip': 'z-[45]',
'z-toast': 'z-[50]',
'z-modal-backdrop': 'z-[60]',
'z-modal-content': 'z-[70]',
'z-drawer-backdrop': 'z-[80]',
'z-drawer-content': 'z-[90]',
},
})
Pair it with
@unocss/eslint-pluginfor the feedback loop — it surfaces the blocklist hints (e.g. a plainz-50) in your editor and CI instead of silently dropping at build time.
// Components are imported by full path (no barrel) — categorized and prefixed:
import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue'
import OverlayModal from '@antfu/design/components/Overlay/OverlayModal.vue'
import '@antfu/design/styles.css' // everything, incl. splitpanes/reka/vue-afloat overrides
styles.css bundles every overlay engine's override. If you don't use them all,
cherry-pick instead so unused (splitpanes/reka-ui) CSS never ships:
import '@antfu/design/styles/base.css'
import '@antfu/design/styles/scrollbar.css'
import '@antfu/design/styles/vue-afloat.css' // only if you use tooltips
The package ships raw .ts / .vue source (no bundling) — your build
compiles it. No extra content config is needed: UnoCSS's default scan matches
.vue/.tsx by extension (its only default exclude is CSS, not node_modules),
so the components you import are picked up automatically. If you do set
content.pipeline.include, note it replaces the default scan rather than
extending it — restate the defaults too, or your own sources stop being generated.
It's a single preset that is not self-contained: it contributes only the
antfu design layer (theme tokens, semantic shortcuts, dynamic rules, severity).
You compose the base preset, icons, fonts and reset yourself — and add
@unocss/transformer-directives (the shipped styles use token --at-apply
directives) plus, recommended, @unocss/eslint-plugin for the feedback loop.
Exports
| Subpath | What |
|---|---|
./components/* |
one readable .vue per component (e.g. ./components/Display/DisplayBadge.vue) |
./unocss |
the single presetAnthonyDesign preset |
./utils |
color, format, path, semver, contrast, keybinding helpers (pure, stateless) |
./composables/* |
Vue helpers: colorScheme (opt-in scheme context) and toast (useToast queue) |
./a11y |
programmatic color-contrast scan |
./styles.css, ./styles/* |
all styles, or per-concern files |
./splitpanes.d.ts |
opt-in fallback types for older splitpanes (v4.1.2+ ships its own) |
The package is stateless — no dark-mode/clipboard/toast state. Components that vary by scheme take a
colorSchemeprop; toasts are controlled. Use VueUse directly for state.Two opt-in helpers reduce the boilerplate without adding global state:
provideColorScheme(() => isDark ? 'dark' : 'light')(from@antfu/design/composables/colorScheme) lets scheme-aware components inherit the scheme instead of threading a prop, anduseToast()(from@antfu/design/composables/toast) owns a toast queue forFeedbackToasts. Both only read state you own.
Migrating an existing app
If your app predates the design system, adoption is mostly deletion:
- Delete your local token shortcuts. Apps already on the
*-basedialect (bg-base,color-base,border-base,op-fade,btn-action,badge-color-*,color-scale-*, …) can drop those hand-rolleduno.config.tsshortcutsblocks — often 20–170 lines — and inherit them frompresetAnthonyDesign. Setprimary/darkBackgroundto your existing values (and keep your existingpresetWebFontsconfig as-is — this preset has no opinion on fonts) for a near-zero visual diff. - Dedupe local utilities into
@antfu/design/utils: the duplicatedgetHashColorFromString/getPluginColor, the byte / duration / time-ago formatters, module-id / path parsing, and semver-range parsing all live there. - Bump off the legacy
presetUnoalias.presetUnois the old name forpresetWind3; switch to a current base (presetWind4recommended, orpresetWind3) so the semantic shortcuts have utilities to expand into. You still bring your own icons, fonts, and@unocss/reset— the preset bundles none. - Thread the scheme, not state. The package owns no dark/toast state: pass
:color-scheme(or callprovideColorScheme()once at the root) and move imperative toast singletons onto auseToast()queue.
Then follow the redesign protocol in the skill (advanced-patterns): map raw
colors → tokens, swap raw elements for primitives one family at a time, and run the
contrast scan in light + dark after each step.
Embedding in a shadow root
Mounting the components inside a shadow root — a web component, a devtools dock, an injected overlay — is supported, but two things do not cross the boundary on their own:
- The scheme class goes on an ancestor, never on the element you style. The
dark variant compiles to a descendant selector (
.dark .bg-base), so a node that is.darkdoesn't match its own dark rules — the subtree renders light-themed inside a dark host. Toggledark/light(andcolor-scheme) on a wrapper —display: contentsworks, so it costs no layout — and put the surface utilities on its child.:host(.dark)is not an alternative: these are descendant selectors, and they can't reach across the boundary. - Global CSS can't pierce the boundary — inline the styles.
@antfu/design/styles/*(scrollbar, reka-ui animations, vue-afloat, …) must be part of the sheet you inject into the root, alongside the UnoCSS output. They're written with*/ unscoped selectors precisely so they work in either place. Addscrollbar-gutter: stableon your own scroll container if you want the gutter reserved.
Portaled content needs no setup. Every component that teleports — the dialogs
(OverlayModal, OverlayDrawer, OverlayConfirm), the poppers (FormSelect,
FormCombobox, OverlayDropdown, OverlayHoverCard, OverlayContextMenu,
LayoutMenubar) and FeedbackToasts — detects the enclosing shadow root and
teleports into a container appended to it, which also mirrors the dark/light
class of the nearest scheme wrapper so dark-mode tokens keep resolving there. Pass
an explicit to (a selector or element) to override the target. The
Utilities/ShadowRoot story mounts them all inside a shadow root.
Accessibility
A color-contrast scan (axe-core + Playwright) runs a URL in light and dark mode. Use it programmatically:
import { formatContrastReport, runContrastScan } from '@antfu/design/a11y'
const result = await runContrastScan({ url: 'http://localhost:6006/iframe.html' })
console.log(formatContrastReport(result))
…or run the bundled script with tsx:
tsx node_modules/@antfu/design/a11y/cli.ts http://localhost:6006/iframe.html
Components
Action
| Component | Use it for |
|---|---|
ActionButton |
actions. variant action/primary/text, polymorphic href/as, icon, loading. |
ActionIconButton |
a round icon-only button with a tooltip, active state, #badge. Compose with VueUse's useDark for a dark toggle. |
ActionToggle |
a single pressed/unpressed toggle button (icon, label). |
ActionToggleGroup |
a segmented set of toggles from options, single- or multi-select (multiple), optionally icon-only. |
Display
| Component | Use it for |
|---|---|
DisplayAvatar |
an image avatar that falls back to hash-colored initials from name when src is absent/fails. |
DisplayBadge |
a status/type/tag chip — hash-colored from text, a palette name (color="green"), a hue, or muted. colorScheme, variant, icon, rounded (md/full/em number), paddingX/paddingY; sized by its own font-size. |
DisplayBytes |
humanized size, colorize, percent of total. |
DisplayDate |
relative time + exact-date tooltip, colorize by age, live. |
DisplayDonut |
a progress ring. |
DisplayDuration |
ms → human, colorize by severity. |
DisplayFileIcon |
ext/path → icon via a configurable rule list (rules, folderRules), with an invert escape hatch for dark icon sets on light surfaces. |
DisplayFilePath |
truncates, dims directories, decodes .pnpm, icon, link. |
DisplayIconifyRemoteIcon |
renders an Iconify icon by name without a bundled icon set, fetching from the Iconify API. |
DisplayKbd |
renders a chord string (e.g. mod+k) as platform glyphs. |
DisplayKeyValue |
a labeled stat — inline row or stacked, mono value, #badge adornment. |
DisplayLabel |
a fully-rounded DisplayBadge (tighter padding) for GitHub-style tags (colorScheme). |
DisplayNumber |
formatted numbers (Intl), mono + tabular, prefix/suffix. |
DisplayNumberBadge |
a DisplayNumber in a badge shell — a count pill. |
DisplayPackageName |
scope colored by hash (colorScheme). |
DisplayProgressBar |
a linear progress track — determinate (value 0–1) or indeterminate when omitted, color, rounded. |
DisplayProportionBar |
a stacked proportion bar. |
DisplaySafeImage |
an <img> that swaps to the #fallback slot on a missing/failed src, with an optional preload to avoid a flash. |
DisplayTree |
nests a flat items list (via getPath) or pre-built nodes into an expandable file/folder-style tree, with #leaf/#default slot overrides. |
DisplayVersion |
vX.Y.Z prefix logic; passes specs/ranges (workspace:*, ^1.2) through untouched. |
Form
| Component | Use it for |
|---|---|
FormCheckbox |
a checkbox control bound with v-model. |
FormCombobox |
a searchable, filterable select (reka-ui Combobox) over an options list. |
FormField |
wraps a control with a label, description, and error (role="alert"), required marker. |
FormNumberInput |
a numeric input with min/max/step clamping and optional −/+ stepper controls. |
FormRadioGroup |
a radio-button group bound with v-model. |
FormSearchField |
a search input — icon + DisplayKbd shortcut hint + clear button. |
FormSegmentedControl |
a single-select segmented control (reka-ui RadioGroup) over an options list, including a valid null segment. |
FormSelect |
a native-feeling select bound with v-model. |
FormSwitch |
an on/off switch bound with v-model. |
FormTextInput |
a single-line text input, icon, invalid state. |
FormTextarea |
a multi-line text input, rows, invalid state, resize toggle. |
Overlay
| Component | Use it for |
|---|---|
OverlayConfirm |
an AlertDialog for a confirm/cancel decision, variant="danger" for destructive actions. |
OverlayContextMenu (+ OverlayContextMenuItem, OverlayContextMenuLabel, OverlayContextMenuSeparator) |
a right-click menu. |
OverlayDrawer |
a slide-in panel from any side (left/right/top/bottom), sized by width. |
OverlayDropdown (+ OverlayDropdownCheckboxItem, OverlayDropdownGroup, OverlayDropdownLabel, OverlayDropdownRadioGroup, OverlayDropdownRadioItem, OverlayDropdownSeparator, OverlayDropdownSub) |
a trigger-anchored dropdown menu. |
OverlayDropdownItem |
a selectable dropdown row — icon, variant="danger", trailing shortcut (rendered via DisplayKbd). |
OverlayHoverCard |
a hover-triggered popover with configurable openDelay/closeDelay. |
OverlayModal |
a centered dialog (title, description, #trigger slot). |
OverlayTooltip |
a vue-afloat tooltip — text content or the #content slot, configurable placement/delay/triggers, plus a virtual-anchor mode for canvas/graph overlays. |
Layout
| Component | Use it for |
|---|---|
LayoutAccordion |
collapsible sections from items; one entry = a single section, multiple for several open at once. |
LayoutBreadcrumb |
a linked path trail from items; the last crumb renders as plain text. |
LayoutCard |
a bordered content surface. |
LayoutDataTable |
a sortable, generic data table from columns + rows, optional stickyHeader. |
LayoutExpandableList |
"show N more" truncation for a list. |
LayoutMenubar |
a desktop-style menu bar from a menus list of MenubarMenuEntry. |
LayoutPagination |
page-number navigation. |
LayoutPanelGrids |
a dots/grid background pattern surface for empty states or canvases. |
LayoutScrollArea |
a custom-scrollbar scroll container. |
LayoutSeparator |
a horizontal/vertical divider, optionally with a centered label. |
LayoutSideNav |
a vertical nav list from items, active-item highlighting via v-model. |
LayoutSplitPane |
resizable split panes (wraps splitpanes). |
LayoutTabs |
underline/segment tabs, count chips. |
LayoutToolbar |
a sticky, glass-surfaced action bar with #start/#search/#end slots. |
LayoutVirtualList |
a virtualized list (@tanstack/vue-virtual) for large collections. |
Feedback
| Component | Use it for |
|---|---|
FeedbackEmptyState |
an empty/zero-state placeholder — icon, title, description, #action slot. |
FeedbackLoading |
a loading placeholder wrapping FeedbackSpinner. |
FeedbackSkeleton |
a shimmer placeholder block for loading content. |
FeedbackSpinner |
a spinner glyph. |
FeedbackTip |
an inline callout — info/success/warning/error. |
FeedbackToasts |
a presentational toast stack (items, position) — the app owns the list and ids via its own useToast(). |
Full catalog with import paths: core-components.md.
Tokens
Semantic & composite shortcuts
| Token | Expands to |
|---|---|
color-base |
color-base-light dark:color-base-dark |
color-base-light |
color-neutral-800 |
color-base-dark |
color-neutral-200 |
color-muted |
color-neutral-600 dark:color-neutral-400 |
color-faint |
color-neutral-500 dark:color-neutral-500 |
color-active |
color-primary-600 dark:color-primary-300 |
bg-base |
bg-white dark:bg-#111 |
bg-secondary |
bg-#f6f6f6 dark:bg-#101010 |
bg-raised |
bg-white/65 dark:bg-black/60 |
bg-sunken |
bg-black/4 dark:bg-white/6 |
bg-active |
bg-#99999920 |
bg-ambient |
bg-#99999925 |
bg-hover |
bg-#99999915 |
bg-code |
bg-gray-500/5 |
bg-tooltip |
bg-white/75 dark:bg-#111/75 backdrop-blur-8 |
bg-gradient-more |
bg-gradient-to-t from-white via-white/80 to-white/0 dark:from-#111 dark:via-#111/80 dark:to-#111/0 |
border-base |
border-#9992 |
border-mute |
border-#9991 |
border-active |
border-primary-600/25 dark:border-primary-400/25 |
ring-base |
ring-#9992 |
op-fade |
op65 |
op-mute |
op40 |
op-active |
op100 |
op-disabled |
op30 |
icon-catppuccin |
invert-100 hue-rotate-180 brightness-80 dark:invert-0 dark:hue-rotate-0 dark:brightness-100 |
btn-action |
border border-base rounded flex gap-2 items-center px2 py1 op75 hover:op100 hover:bg-hover transition disabled:pointer-events-none disabled:op30! outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 |
btn-action-sm |
btn-action text-sm |
btn-action-active |
color-active border-active! bg-active op100! |
btn-icon |
w-9 h-9 rounded-full op-fade hover:op100 hover:bg-hover transition flex items-center justify-center disabled:pointer-events-none disabled:op30 outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 |
btn-icon-compact |
w-6 h-6 rounded op-fade hover:op100 hover:bg-hover transition flex items-center justify-center disabled:pointer-events-none disabled:op30 outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 |
btn-icon-square |
w-9 h-9 rounded border border-base op-fade hover:op100 hover:bg-hover transition flex items-center justify-center disabled:pointer-events-none disabled:op30 outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 |
btn-primary |
px2 py1 rounded border border-transparent flex gap-2 items-center bg-primary-500 hover:bg-primary-600 text-white transition disabled:op50 disabled:pointer-events-none outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 |
btn-text |
px2 py1 rounded border border-transparent inline-flex gap-2 items-center op75 hover:op100 hover:bg-hover transition disabled:pointer-events-none disabled:op30! outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40 |
badge |
inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-xs font-medium leading-none |
badge-active |
badge bg-active color-active |
badge-muted |
badge bg-#8881 color-muted |
pad-safe-t |
pt-[env(safe-area-inset-top)] |
pad-safe-r |
pr-[env(safe-area-inset-right)] |
pad-safe-b |
pb-[env(safe-area-inset-bottom)] |
pad-safe-l |
pl-[env(safe-area-inset-left)] |
pad-safe-x |
pad-safe-l pad-safe-r |
pad-safe-y |
pad-safe-t pad-safe-b |
pad-safe |
pad-safe-x pad-safe-y |
Severity scale
| Token | Expands to |
|---|---|
color-scale-neutral |
text-gray-700 dark:text-gray-300 |
color-scale-low |
text-lime-700 dark:text-lime-300 dark:saturate-75 |
color-scale-medium |
text-amber-700 dark:text-amber-300 dark:saturate-90 |
color-scale-high |
text-orange-700 dark:text-orange-300 |
color-scale-critical |
text-red-700 dark:text-red-300 |
Type sizes
| Token | Expands to |
|---|---|
text-micro |
text-[10px] leading-[1.4] |
text-mini |
text-[11px] leading-[1.45] |
text-compact |
text-[12px] leading-[1.5] |
Dynamic
| Token | Expands to |
|---|---|
badge-color-<name> |
a chip tinted by any palette color name (dark-aware) |
bg-glass / bg-glass:<n> |
translucent surface + backdrop-blur |
bg-dots / bg-dots-<n> |
radial dot-grid background, variable cell size in px (default 16) |
bg-grid / bg-grid-<n> |
crosshatch grid-lines background, variable cell size in px (default 16) |
scroll-fade / scroll-fade-{x,y,t,b,l,r,s,e} |
scroll-aware edge fade (scroll-fade-<n>, scroll-fade-none); pairs with no-scrollbar |
shimmer / shimmer-{once,reverse,none} |
text shimmer over currentColor (shimmer-{color,duration,spread,angle}-*) |
License
Related Packages
Version History
| Version | Released | License |
|---|---|---|
| 0.6.0 | MIT | |
| 0.5.3 | MIT | |
| 0.5.2 | MIT | |
| 0.5.1 | MIT | |
| 0.5.0 | MIT | |
| 0.4.0 | MIT | |
| 0.3.5 | MIT | |
| 0.3.4 | MIT | |
| 0.3.3 | MIT | |
| 0.3.2 | MIT | |
| 0.3.1 | MIT | |
| 0.3.0 | MIT | |
| 0.2.2 | MIT | |
| 0.2.1 | MIT | |
| 0.2.0 | MIT | |
| 0.1.0 | MIT |