Components & copy-in UI
Kovo gives you two supported ways to use styled components, with behavior and styling kept deliberately separate.
@kovojs/headless-uiis a public, versioned package. It ships the behavior: the accessible attribute builders (selectTriggerAttributes,dialogContentAttributes, …), URL helpers, and the headless types that describe a component's render inputs (SelectItem,ComboboxItem, …). You install it and import from it like any dependency.@kovojs/uiis the public styled component package. Import direct component subpaths such as@kovojs/ui/buttonwhen you want versioned components. The same source can also be copied into your app withkovo addwhen you want to own the component implementation.
This guide covers direct imports, the copy-in flow, typed StyleX overrides, and the public packages styled components build on.
Direct component imports#
Install the public packages and import each component from its component subpath:
npm install @kovojs/ui @kovojs/style @kovojs/headless-ui @kovojs/core @kovojs/serverimport { Button } from '@kovojs/ui/button';
export function Toolbar() {
return <Button variant="primary">Save</Button>;
}Every styled component takes a typed style (or styles) override prop; the mechanics live in
Styling → Overrides.
Use this mode when the versioned package behavior and styling are close to what your app needs.
@kovojs/ui deliberately has no root export: component symbols live on component subpaths so an
import always names the component and each symbol has one public home. Styled components use the
@kovojs/style system token contract by default, so changing the app theme seed changes their
surface, foreground, border, and state colors without editing each component.
Headless behavior follows the same subpath rule. @kovojs/headless-ui has no public root import;
import each primitive's attribute builders from its primitive subpath:
import { dialogContentAttributes } from '@kovojs/headless-ui/dialog';
import { selectTriggerAttributes } from '@kovojs/headless-ui/select';Icons are also one glyph per subpath, with shared props at the root:
import type { IconProps } from '@kovojs/icons';
import { Search } from '@kovojs/icons/search';Find a component or icon#
Open global search with ⌘K (or Ctrl K) and type a component name, anatomy part, ARIA role, keyboard behavior, icon name, or import path. The index covers all 44 styled components and all 1,737 icon glyphs. Component results open the live fixture; icon results return here with their exact import path.
The checked-in catalog/component-icon-catalog.json is the same machine-readable index for tools
and agents. It combines two package-owned inputs under one schema:
packages/ui/catalog.jsondescribes component anatomy, enhancement tier, roles, keyboard and accessibility behavior, direct import, and copy command.packages/icons/catalog.jsondescribes every glyph and its direct import.
The UI/headless generator and icon generator remain independent owners. The combined catalog is a discovery view, not a second source of truth.
Card anatomy#
Card has one stable six-part anatomy in source, package exports, copy-in output, and the catalog:
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@kovojs/ui/card';
export function AccountCard() {
return (
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Manage your profile and sign-in settings.</CardDescription>
</CardHeader>
<CardContent>Profile controls</CardContent>
<CardFooter>Last saved just now</CardFooter>
</Card>
);
}Icons#
Import a glyph from its direct subpath. IconProps stays at the icons root because it is the one
shared author contract; every glyph returns Kovo's canonical ComponentRenderResult.
import type { IconProps } from '@kovojs/icons';
import { ArrowRight } from '@kovojs/icons/arrow-right';
export function ContinueIcon(props: IconProps) {
return <ArrowRight aria-label="Continue" {...props} />;
}Icons are decorative when they have no accessible name. Give a meaningful standalone icon an
aria-label or title; do not repeat adjacent visible text.
Copy-in components#
Copy a component when you want to own the component source. The only thing that changes from the
direct-import case is where you import the component from — ./components/ui/button.js instead of
@kovojs/ui/button. It still takes the same typed StyleX override prop (see
Styling → Overrides).
A copied component depends only on public, versioned packages:
@kovojs/style— typed StyleX objects, property-level merge, tokens, themes, and readable atomic CSS.@kovojs/headless-ui— the*Attributesbuilders and headless render-input types.@kovojs/core—component(), the server component constructor.@kovojs/server— the JSX runtime used by copied TSX files.
The flow#
Install the public dependencies:
npm install @kovojs/style @kovojs/headless-ui @kovojs/core @kovojs/serverCopy the component source and any sibling files listed by the registry, such as the shared
theme.tstoken adapter used by styled components:kovo add buttonImport the copied component and pass typed style overrides when needed:
/** @jsxImportSource @kovojs/server */ import * as style from '@kovojs/style'; import { Button } from './components/ui/button.js'; const styles = style.create({ danger: { backgroundColor: style.tokens.sys.color.errorContainer, color: style.tokens.sys.color.onErrorContainer, }, }); export function Toolbar() { return ( <Button variant="primary" style={styles.danger}> Delete </Button> ); }
The copied source is plain TSX. It uses the @kovojs/server JSX runtime
(/** @jsxImportSource @kovojs/server */ at the top of each file) and renders to attributes the
headless layer defines, so it works in SSR pages, mutation fragments, and deferred streams the same
way your own components do.
What a styled component looks like#
A static component imports @kovojs/style and component():
/** @jsxImportSource @kovojs/server */
import { component } from '@kovojs/core';
import * as style from '@kovojs/style';
import type { ButtonVariant } from '@kovojs/ui/button';
export const buttonStyles = style.create({
root: {
alignItems: 'center',
borderRadius: style.tokens.sys.shape.cornerMedium,
display: 'inline-flex',
fontSize: 14,
justifyContent: 'center',
},
primary: {
backgroundColor: style.tokens.sys.color.primary,
color: style.tokens.sys.color.onPrimary,
},
secondary: {
backgroundColor: style.tokens.sys.color.surface,
color: style.tokens.sys.color.onSurface,
},
ghost: {
backgroundColor: 'transparent',
color: style.tokens.sys.color.onSurface,
},
destructive: {
backgroundColor: style.tokens.sys.color.error,
color: style.tokens.sys.color.onError,
},
outline: {
borderColor: style.tokens.sys.color.outline,
borderStyle: 'solid',
borderWidth: 1,
color: style.tokens.sys.color.onSurface,
},
});
export interface ButtonProps {
children?: string;
style?: style.StyleInput;
variant?: ButtonVariant;
}
export const Button = component({
render(props: ButtonProps) {
return (
<button style={[buttonStyles.root, buttonStyles[props.variant ?? 'primary'], props.style]}>
{props.children}
</button>
);
},
});A component with real interaction behavior adds the headless attribute builders. The select trigger,
for instance, pulls its ARIA and data-* attributes from selectTriggerAttributes in
@kovojs/headless-ui rather than spelling out the state machine by hand:
/** @jsxImportSource @kovojs/server */
import { component } from '@kovojs/core';
import {
selectTriggerAttributes,
type SelectTriggerAttributeOptions,
} from '@kovojs/headless-ui/select';
import * as style from '@kovojs/style';
const selectStyles = style.create({
trigger: { alignItems: 'center', display: 'inline-flex', gap: 8 },
});
export interface SelectTriggerProps extends SelectTriggerAttributeOptions {
label: string;
styles?: { trigger?: style.StyleInput };
}
export const SelectTrigger = component({
render(props: SelectTriggerProps) {
// The builder returns the ARIA + data-* attributes for the trigger's current state.
const attrs = selectTriggerAttributes({
id: props.id,
labelledBy: props.labelledBy,
listboxId: props.listboxId,
open: props.open,
value: props.value,
items: props.items,
});
return (
<button
style={[selectStyles.trigger, props.styles?.trigger]}
id={attrs.id}
aria-controls={attrs['aria-controls']}
aria-expanded={attrs['aria-expanded']}
aria-haspopup={attrs['aria-haspopup']}
aria-labelledby={attrs['aria-labelledby']}
data-state={attrs['data-state']}
data-placeholder={attrs['data-placeholder']}
>
{props.label}
</button>
);
},
});You spread the builder's output onto the host element rather than hand-writing the ARIA contract;
the compiler wires the interactive on:* handlers and data-bind updates onto the same element
when the component is enhanced. For copied TSX, plain JSX children already escape text content; if a
component needs extra string shaping, keep that helper local to the copied file instead of reaching
into @kovojs/server/internal/html.
Because the behavior lives in @kovojs/headless-ui, your copy stays small: it owns markup and
StyleX objects, the public package owns correctness.
Server render inputs, not client state. Kovo's styled components are server components: they render once on the server and emit attributes. The
*StatePropsinterfaces you'll see on interactive components (e.g.SelectStatePropswithitems,listboxId,highlightedValue) are the render inputs the server needs to emit the right headless attributes — not a client-side state machine leaking out. Leave them as-is unless you're changing what the component renders.
The registry#
The package ships a machine-readable manifest, packages/ui/registry.json, listing every component:
its source file(s), exported symbols, and the exact public package symbols it imports (plus any
sibling files to copy alongside it). public-packages.json declares @kovojs/ui distribution
mode as package-and-copy-in, so the generated registry records both the package-managed and copy-in
paths from the same source of truth. The current registry spans 44 components,
tracks anatomy, enhancement, keyboard, and accessibility metadata for every component, and
limits copied source imports to @kovojs/core, @kovojs/icons, @kovojs/server, @kovojs/browser, @kovojs/headless-ui, and @kovojs/style. This is the data
kovo add <component> consumes to copy a component and its dependencies into your app. It is
also enforced: a copy-in smoke test typechecks representative components against the public
packages alone, so a component cannot start depending on a non-public symbol without the build
catching it.
Choosing a mode#
Use @kovojs/ui/<component> imports when you want package-managed updates. Use kovo add when the
component is a starting point and future changes should live in your app.
Next#
- Composing primitives - merge headless behavior into your own elements without guessing at the attribute rules.
- Styling with StyleX — typed component styles, plain document CSS, and the stylesheet contract.
- Accessibility — the behavior
@kovojs/headless-uibakes into every primitive. - Stability & Versioning — public package boundaries and import stability.
Spec & diagnostics
Component model and component(): SPEC §5. The styled components are emitted as TSX/JSX source and
lowered by the compiler (SPEC §5.2); hand-authored lowered IR is KV235. The public package boundary
for @kovojs/ui, @kovojs/headless-ui, and @kovojs/style is recorded in
plans/api-export-cleanup.md and the repo STABILITY.md.
API reference: @kovojs/core, @kovojs/headless-ui, @kovojs/icons, @kovojs/server, @kovojs/style, @kovojs/ui.