Skip to content
Development
Agent

ui-styling

This guide covers the rules and patterns for authoring styles in `packages/ui`. The system uses [vanilla-extract](https://vanilla-extract.style/) (VE) with CSS `@layer` ordering, shared recipes, and composition over inheritance.

From plugin
emdash
5.4k24 skills24 agents

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

This guide covers the rules and patterns for authoring styles in `packages/ui`. The system uses [vanilla-extract](https://vanilla-extract.style/) (VE) with CSS `@layer` ordering, shared recipes, and composition over inheritance.

Agent definition

ui-styling.md

UI Styling Conventions

This guide covers the rules and patterns for authoring styles in `packages/ui`. The system uses [vanilla-extract](https://vanilla-extract.style/) (VE) with CSS `@layer` ordering, shared recipes, and composition over inheritance.

Core Principle: One Owner Per Property

Every CSS property on an element should have exactly **one authoritative source** of truth. When two rules set the same property at the same specificity, source order decides — which is fragile and hard to reason about.

**Anti-pattern (two owners fighting):**

// primitive: always sets padding 0.25rem
export const comboboxList = style({ padding: '0.25rem' });
// consumer: also sets padding → source-order roulette
export const list = style({ padding: '0.25rem' });

**Correct (single owner):**

// primitive owns padding; consumer just composes it
// consumer delegates to primitive, no override
<ComboboxList> {/* no extra className needed */}

State = Variant, Not Higher-Specificity Selector

Express component state through `data-*` attributes handled inside the owning `style()` or `recipe()`, not by adding specificity from the outside.

**Anti-pattern (state via higher specificity):**

// parent component pushing state into child via specificity escalation
globalStyle(`${someParent} ${childClass}`, { padding: 0 });

**Correct (state-owned selector inside the primitive):**

export const comboboxList = style({
  padding: '0.25rem',
  selectors: {
    '&[data-empty]': { padding: 0 },  // library-owned state drives own style
  },
});

No globalStyle Across Component Boundaries

`globalStyle` with a multi-segment selector that crosses a component boundary (e.g. `${parentClass} [data-slot="child"]`) creates invisible coupling. If the child's class changes, the parent silently breaks.

**Anti-pattern (cross-boundary globalStyle):**

// combobox.css.ts reaching into input-group internals
globalStyle(`${comboboxContent} [data-slot="input-group"]`, {
  borderRadius: 0,
  boxShadow: 'none',
});

**Correct (variant prop on the child):**

// input-group.css.ts adds an "embedded" variant
export const inputGroup = recipe({
  variants: {
    variant: {
      embedded: { borderRadius: 0, boxShadow: 'none' },
    },
  },
});
// consumer passes the variant explicitly
<InputGroup variant="embedded" />

Prefer Composition Over Inheritance

Reuse styles by **composing** `style()` arrays rather than inheriting through class hierarchies or overrides.

// compose shared base into component-specific style
export const menuItem = style([
  menuItemBase(),          // shared structural recipe
  {
    selectors: { '&:focus': { backgroundColor: vars.surfaceHover } },
  },
]);

VE's `style([...])` merges multiple style objects/classes into one atomic class at build time. The `recipe()` base also accepts an array:

recipe({ base: [sharedBase, { componentSpecific: '...' }] })

SVG Sizing: Use svg-helpers

Use the shared helpers from `@styles/effects/svg-helpers.css` instead of hand-rolling `globalStyle` for SVG sizing.

| Helper | Effect | |--------|--------| | `svgContainer` | `svg { pointer-events: none; flex-shrink: 0 }` | | `svgDefaultSize` | `svg:not([class*='size-']) { width: 1rem; height: 1rem }` | | `svgSmSize` | `svg:not([class*='size-']) { width: 0.75rem; height: 0.75rem }` | | `svgTextSize` | same as default (alias for inline text contexts) |

export const menuItem = style([svgContainer, svgDefaultSize, { /* ... */ }]);

Shared Recipes for Common Patterns

menuItemBase

`@styles/recipes/menu-item.css` — structural recipe for all list item rows (DropdownMenu, Select, Combobox, ComboboxPopup).

import { menuItemBase } from '@styles/recipes/menu-item.css';

export const myItem = style([
  menuItemBase({ trailingIndicator: true, fullWidth: true }),
  { selectors: { '&:focus': { backgroundColor: vars.surfaceHover } } },
]);

Variants: `trailingIndicator`, `fullWidth`, `inset`, `muted`.

popupSurface + popupShadow*

`@styles/recipes/popup-surface.css` — base style for floating popup containers with the animation keyframe selectors and visual properties already wired up.

import { popupSurface, popupShadowMd } from '@styles/recipes/popup-surface.css';

export const myMenu = style([
  popupSurface,
  popupShadowMd,
  { minWidth: '12rem', padding: '0.25rem' },
]);

Shadow variants: `popupShadowSm` (tooltips, comboboxes), `popupShadowMd` (menus, selects).

InputGroup variant prop

`InputGroup` accepts a `variant` prop:

  • `default` — standalone field with border, shadow, and focus ring
  • `embedded` — bottom-border-only divider for use inside popup containers
// ComboboxInput uses "embedded" automatically
<InputGroup variant="embedded" />

Input bare prop

`Input` accepts a `bare` boolean that strips its standalone border/shadow/focus ring. `InputGroupInput` passes `bare` automatically; consumers should not need it directly.

CSS @layer Discipline

The layer order is: `reset < tokens < base < recipes < utilities`.

  • `reset` / `base` — `globalStyle` rules in `reset.css.ts` and `base.css.ts`
  • `recipes` — component `style()` and `recipe()` output (target destination)
  • `utilities` — `sx()` sprinkles; always overrides component styles

**Migration path:** To place a style in the `recipes` layer, wrap properties inside `'@layer': { recipes: { ... } }`:

export const foo = style({
  '@layer': {
    recipes: {
      color: vars.foreground,
      selectors: { '&:hover': { backgroundColor: vars.surfaceHover } },
    },
  },
});

> **Important:** Unlayered styles always beat ALL layered styles. The migration must > be coordinated — mixing layered and unlayered styles for the same property on the > same element will make the unlayered one always win regardless of intent. > Migrate an entire property-ownership group at once.

Overrides Go Through utilities Layer

If a consumer g

Read more
Ships withemdash

Emdash is the Open-Source Agentic Development Environment (🧡 YC W26). Run multiple coding agents in parallel. Use any provider.

Get the whole plugin
Stats
5,373
Stars
554
Forks
Active
Maintenance
TypeScript
Language
Apache-2.0
License
21h ago
Last commit
11mo ago
Created

Repo: generalaction/emdash