Menu

Guides

View as Markdown

Components & copy-in UI

Kovo gives you two supported ways to use styled components, with behavior and styling kept deliberately separate.

  • @kovojs/headless-ui is 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/ui is the public styled component package. Import direct component subpaths such as @kovojs/ui/button when you want versioned components. The same source can also be copied into your app with kovo add when 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:

sh
npm install @kovojs/ui @kovojs/style @kovojs/headless-ui @kovojs/core @kovojs/server
tsx
import { 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:

ts
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:

tsx
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.json describes component anatomy, enhancement tier, roles, keyboard and accessibility behavior, direct import, and copy command.
  • packages/icons/catalog.json describes 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:

tsx
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.

tsx
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 *Attributes builders and headless render-input types.
  • @kovojs/corecomponent(), the server component constructor.
  • @kovojs/server — the JSX runtime used by copied TSX files.

The flow#

  1. Install the public dependencies:

    sh
    npm install @kovojs/style @kovojs/headless-ui @kovojs/core @kovojs/server
  2. Copy the component source and any sibling files listed by the registry, such as the shared theme.ts token adapter used by styled components:

    sh
    kovo add button
  3. Import the copied component and pass typed style overrides when needed:

    tsx
    /** @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():

tsx
/** @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:

tsx
/** @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 *StateProps interfaces you'll see on interactive components (e.g. SelectStateProps with items, 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#

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.