Create or modify a component
Adding a component means working down the layers: write the Solid component, give it a story, then wrap it as a kai-* element. Modifying one is the same path in reverse — find the layer that owns the behavior and change it there. We’ll build a toggle the whole way through; it’s the shortest component that touches every step.
1. Write the component
Section titled “1. Write the component”UI primitives go in src/ui/. Keep them accessible and theme-tokened — no hard-coded colors. The real Switch is a role="switch" button that supports both controlled and uncontrolled state:
import { createSignal } from 'solid-js';import { cn } from '../utils/cn';
export interface SwitchProps { checked?: boolean; // controlled — drive from onChange defaultChecked?: boolean; // initial state when uncontrolled disabled?: boolean; label?: string; onChange?: (checked: boolean) => void;}
export function Switch(props: SwitchProps) { const [internal, setInternal] = createSignal(props.defaultChecked ?? false); const isControlled = () => props.checked !== undefined; const isOn = () => (isControlled() ? !!props.checked : internal());
const toggle = () => { if (props.disabled) return; const next = !isOn(); if (!isControlled()) setInternal(next); props.onChange?.(next); };
return ( <button type="button" role="switch" aria-checked={isOn()} aria-label={props.label} disabled={props.disabled} onClick={toggle} class={cn('inline-flex …', isOn() ? 'bg-primary' : 'bg-muted')} > {/* thumb */} </button> );}Two conventions worth copying: support a controlled checked and an uncontrolled defaultChecked, and style with token-backed Tailwind classes (bg-primary, bg-muted, ring-ring) so theming and dark mode come for free.
2. Give it a story
Section titled “2. Give it a story”Stories are how you build and review a component in isolation — they’re not an afterthought. A primitive’s story sits beside it in src/ui/ and uses componentDescription for the docs blurb:
import type { Meta, StoryObj } from 'storybook-solidjs-vite';import { fn } from 'storybook/test';import { Switch } from './switch';import { componentDescription } from '../stories/docs/element-controls';
const meta = { title: 'Solid (Advanced)/Primitives/Switch', component: Switch, tags: ['autodocs'], parameters: { docs: { description: componentDescription([ 'A toggle switch (`role="switch"`) for an immediate on/off setting.', '**When to use:** a setting that applies the moment it flips.', ]), }, }, argTypes: { disabled: { control: 'boolean' }, label: { control: 'text' }, onChange: { action: 'change', table: { category: 'Events' } }, }, args: { disabled: false, label: 'Temporary chat', onChange: fn() }, render: (args) => <Switch {...args} />,} satisfies Meta<typeof Switch>;
export default meta;type Story = StoryObj<typeof meta>;
export const Playground: Story = {};export const On: Story = { args: { defaultChecked: true } };Run npm run storybook and the story is live. Write a description, a Playground driven by args, and a showcase story for each notable variation. (Element stories — in src/elements/ — differ slightly: they pull controls from the generated metadata with argTypesFor(tag) and write the blurb with specDescription. Copy an existing one like src/elements/switch.stories.tsx.)
3. Expose it as a kai-* element
Section titled “3. Expose it as a kai-* element”To make the primitive usable outside Solid, wrap it with defineWebComponent in src/elements/. This is the whole element:
import { defineWebComponent } from './define';import { Switch } from '../ui/switch';
interface Props extends Record<string, unknown> { checked?: boolean; disabled?: boolean; label?: string;}
interface Events { 'kai-change': { checked: boolean };}
defineWebComponent<Props, Events>('kai-switch', { checked: undefined, disabled: undefined, label: undefined,}, (props, { dispatch, flag }) => ( <Switch defaultChecked={flag('checked')} disabled={flag('disabled')} label={props.label} onChange={(checked) => dispatch('kai-change', { checked })} />));What the facade handles for you:
- Boolean props — use
flag('checked'), notprops.checked. A bare attribute (<kai-switch checked>) parses toundefined, soflagis what makes<kai-switch checked>,checked="true", andel.checked = trueall read as on. - Events —
dispatch('kai-change', { checked })fires akai-*CustomEventoff the host. TypingdefineWebComponent<Props, Events>keepsdispatchhonest about names and payloads. - Slots — for content rather than data, render
<slot />(or named slots) inside the facade; the kit’s composition elements do this for declarative children.
4. Register and regenerate
Section titled “4. Register and regenerate”Add the element to the barrel so the bundle defines it, and re-run the API generator so the metadata, types, and docs pick it up.
import './switch';npm run build:api # regenerates element-meta.json, types, React wrappers, llms.txtThat’s the full loop: the component exists, has a story, ships as <kai-switch>, and shows up everywhere the generated metadata feeds — the reference tables, the React adapter, and the for-AI-agents output.
Verify
Section titled “Verify”Before you call it done: npm run typecheck, npm test, and check the story in npm run storybook in both light and dark. For element behavior that needs a real browser, add a Playwright check rather than a jsdom test.