# Creating Components
How to create a styled component by extending a [dui-primitives](https://github.com/deepfuturenow/dui-primitives) base class. This guide is written for anyone building components on top of `@dui/primitives` — whether in this repo or your own.
For the step-by-step procedure with all file creation and config updates, use the `/create-component` skill.
---
## The model
A DUI component is a styled subclass of an unstyled primitive:
```
DuiBadgePrimitive (from @dui/primitives)
→ structural CSS, ARIA, keyboard behavior
→ extends LitElement
DuiBadge (your component)
→ aesthetic CSS (tokens, variants, colors, sizing)
→ extends DuiBadgePrimitive
→ calls customElements.define() to self-register
```
The primitive handles the hard parts (accessibility, keyboard navigation, focus management). Your component adds the design — tokens, variant systems, colors, spacing, typography.
---
## Minimal example: Badge
### The primitive (from `@dui/primitives`)
This is what you're extending — you don't write this, it comes from the primitives package:
```typescript
// @dui/primitives/badge — provided by dui-primitives
export class DuiBadgePrimitive extends LitElement {
static tagName = "dui-badge" as const;
static override styles = [base, styles]; // structural CSS only
override render(): TemplateResult {
return html``;
}
}
```
### Your styled component
```typescript
// your-components/src/badge/badge.ts
import { css } from "lit";
import { DuiBadgePrimitive } from "@dui/primitives/badge";
import "../_install.ts"; // inject design tokens (see below)
const styles = css`
:host,
:host([variant=""]),
:host([variant="neutral"]) {
--badge-bg: var(--foreground);
--badge-fg: var(--background);
}
:host([variant="primary"]) {
--badge-bg: var(--accent);
--badge-fg: oklch(from var(--accent) 0.98 0.01 h);
}
:host([variant="danger"]) {
--badge-bg: var(--destructive);
--badge-fg: oklch(from var(--destructive) 0.98 0.01 h);
}
[part="root"] {
gap: var(--space-1);
height: var(--space-5);
padding: 0 var(--space-2);
border-radius: var(--radius-full);
background: var(--badge-bg);
color: var(--badge-fg);
font-family: var(--font-sans);
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
}
`;
export class DuiBadge extends DuiBadgePrimitive {
static override styles = [...DuiBadgePrimitive.styles, styles];
}
customElements.define(DuiBadge.tagName, DuiBadge);
```
That's the complete pattern:
1. **Import the primitive** and extend it
2. **Add aesthetic CSS** — variants, tokens, colors, sizing, interaction states
3. **Spread the primitive's styles** and append yours: `[...Primitive.styles, styles]`
4. **Call `customElements.define()`** at module level to self-register
---
## Style composition
Styles are layered via the Lit `styles` array. Later entries override earlier ones:
```
[...DuiBadgePrimitive.styles, styles]
└── structural CSS └── aesthetic CSS
(from primitive) (your addition)
```
Your aesthetic CSS targets the same `[part="root"]` and `:host` selectors that the primitive defines. Because it comes later in the array, it wins in the cascade.
**Never override `render()`** unless you need to change the DOM structure. The primitive owns the template — you only add CSS.
---
## Token injection
Design tokens (CSS custom properties like `--space-4`, `--accent`, `--font-sans`) need to be available globally so shadow DOM can inherit them. DUI injects them into `document.adoptedStyleSheets` via a side-effect module:
```typescript
// _install.ts — runs once via ES module caching
import { tokenSheet } from "./tokens/tokens.ts";
import { proseSheet } from "./tokens/prose.ts";
for (const sheet of [tokenSheet, proseSheet]) {
if (sheet && !document.adoptedStyleSheets.includes(sheet)) {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
}
}
```
Every component imports this: `import "../_install.ts"`. ES module semantics guarantee it executes exactly once.
If you're building your own component set, create your own `_install.ts` with your own token stylesheet.
---
## The two-axis variant system
DUI components use a two-axis pattern for variants:
- **Variant** (intent): `neutral`, `primary`, `danger` — what the color means
- **Appearance** (treatment): `filled`, `outline`, `ghost`, `soft` — how it's rendered
This is implemented with two layers of CSS custom properties:
```css
/* Layer 1: Intent — sets --_intent-* private tokens */
:host([variant="primary"]) {
--_intent-base: var(--accent);
--_intent-base-fg: oklch(from var(--accent) 0.98 0.01 h);
--_intent-subtle: var(--accent-subtle);
--_intent-subtle-fg: var(--accent-text);
}
/* Layer 2: Appearance — maps --_intent-* to --button-* */
:host([appearance="filled"]) {
--button-bg: var(--_intent-base);
--button-fg: var(--_intent-base-fg);
}
:host([appearance="outline"]) {
--button-bg: transparent;
--button-fg: var(--_intent-subtle-fg);
--button-border: var(--_intent-border);
}
/* Base element — consumes final --button-* values */
[part="root"] {
background: var(--button-bg);
color: var(--button-fg);
border: var(--border-width-thin) solid var(--button-border);
}
```
Adding a new intent (e.g., `warning`) or a new appearance (e.g., `soft`) only requires adding the corresponding `:host([...])` block. The two axes compose independently.
Not every component needs two axes. Simple components (badge, spinner) may just use `variant` alone.
---
## What belongs in the component vs. the primitive
| In the primitive | In your component |
|-----------------|-------------------|
| `render()` — DOM structure, slots, ARIA | Aesthetic CSS — colors, spacing, typography |
| Behavioral properties (`disabled`, `orientation`, `type`) | Appearance properties (`variant`, `size`, `appearance`) |
| Keyboard handling, focus management | Variant system (`:host([variant="..."])` selectors) |
| Event dispatching (`customEvent()`) | Sizing system (`:host([size="..."])` selectors) |
| Structural CSS (`display`, `position`, `flex`) | Interaction states (hover, active, focus-visible) |
| Compound component coordination (Lit Context) | Transitions and animations |
**Variant and size properties** are typically bare reflected strings on the primitive (`accessor variant: string = ""`), but the primitive doesn't know or care what values exist. Your component's CSS defines the vocabulary through its `:host([variant="..."])` selectors.
---
## Index and exports
### Component index
The index does a side-effect import (which triggers `customElements.define()`) and re-exports the class:
```typescript
// src/badge/index.ts
import "./badge.ts";
export { DuiBadge } from "./badge.ts";
```
For compound components, import all sub-components:
```typescript
// src/accordion/index.ts
import "./accordion.ts";
import "./accordion-item.ts";
export { DuiAccordion } from "./accordion.ts";
export { DuiAccordionItem } from "./accordion-item.ts";
export { valueChangeEvent, openChangeEvent } from "@dui/primitives/accordion";
```
### Re-exporting events and types
Events and types are defined on the primitive. Re-export them from your component index so consumers have a single import path:
```typescript
export { navigateEvent } from "@dui/primitives/button";
export type { AccordionContext } from "@dui/primitives/accordion";
```
### Package exports (deno.json)
```json
{
"exports": {
"./badge": "./src/badge/index.ts",
"./button": "./src/button/index.ts"
}
}
```
---
## Properties
Properties are split between primitive and component:
**Primitive declares behavioral properties** (typed enums):
```typescript
// In the primitive — changes JS logic, DOM, or ARIA
@property({ type: Boolean, reflect: true })
accessor disabled = false;
@property()
accessor type: "button" | "submit" | "reset" = "button";
```
**Primitive declares appearance properties as bare strings** (component CSS defines the vocabulary):
```typescript
// In the primitive — just a reflected attribute
@property({ reflect: true })
accessor variant: string = "";
@property({ reflect: true })
accessor size: string = "";
```
**Your component does NOT re-declare properties** that the primitive already defines. The CSS handles variant/size logic entirely.
### Properties vs CSS variables
Properties are the **primary public API**. CSS variables are secondary:
| Use a property when... | Use a CSS variable when... |
|------------------------|---------------------------|
| Consumers frequently set the value | Default is almost always fine |
| Value needs TypeScript type checking | Value is a design token override |
| Value affects behavior or accessibility | Value coordinates parent → child (e.g., `--icon-size`) |
### When to create a CSS variable vs. rely on `::part()`
A variable earns its place if it meets at least one of: (1) variants toggle it, (2) other variables derive from it, (3) sizes toggle it, or (4) it needs ancestor cascading. If none apply, consumers use `::part(root)` instead. See [theming.md](./theming.md) for the full philosophy.
---
## Internal state and privacy
Use `@state()` with native private fields for internal state:
```typescript
@state() accessor #open = false;
```
All internal methods use native `#private`:
```typescript
#handleClick = (e: MouseEvent): void => {
if (this.disabled) return;
this.#open = !this.#open;
};
```
---
## Events
Use the `customEvent()` factory from `@dui/core/event` (part of `@dui/primitives`):
```typescript
import { customEvent } from "@dui/core/event"; // resolves to @dui/primitives/core/event
export const navigateEvent = customEvent<{ href: string }>(
"dui-navigate",
{ bubbles: true, composed: true },
);
```
Events are typically defined on the primitive (since they relate to behavior), then re-exported from the component index.
---
## Host styling: protect behavior-critical CSS
The `:host` element is a **public surface** — outer-document styles always beat `:host` rules. If a style is functionally critical (e.g., `display: none` for hiding), it must live on an internal shadow DOM element, not on `:host`.
### Safe on `:host`
- `display: block` / `display: inline-block` — sane defaults
- CSS custom property definitions
- `box-sizing: border-box`
### Must be on internal elements
- `display: none` toggled by state
- `visibility: hidden` / `opacity: 0` for functional state changes
---
## Compound components
### Decision rule
| Children need... | Pattern | Example |
|------------------|---------|---------|
| Simple data only | **Data-driven** — `.items` property, rendered in parent shadow DOM | Select options |
| Open-ended HTML content | **Lit Context** — light DOM children, context for coordination | Accordion items |
Compound component coordination (context, events) lives in the primitive. Your styled components just extend each sub-primitive.
---
## Icon support
Set `--icon-size` and `--icon-color` in your component's aesthetic CSS so slotted `` elements size correctly:
```css
[part="root"] {
--icon-size: var(--button-icon-size);
--icon-color: var(--button-fg);
}
```
---
## Validation checklist
- [ ] Extends the primitive class (not `LitElement` directly)
- [ ] `import "../_install.ts"` for token injection
- [ ] `static override styles = [...Primitive.styles, styles]`
- [ ] `customElements.define()` called at module level
- [ ] Aesthetic CSS uses design tokens only — no hardcoded values
- [ ] Does NOT re-declare properties from the primitive
- [ ] Does NOT override `render()` (unless DOM changes are needed)
- [ ] Uses two-axis variant system where appropriate
- [ ] `index.ts` has side-effect import + named re-exports
- [ ] Events and types re-exported from primitive
- [ ] Export added to `deno.json`
- [ ] `deno check` passes