Skip to content
kitn AI/UI

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.

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:

src/ui/switch.tsx
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.

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:

src/ui/switch.stories.tsx
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.)

To make the primitive usable outside Solid, wrap it with defineWebComponent in src/elements/. This is the whole element:

src/elements/switch.tsx
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'), not props.checked. A bare attribute (<kai-switch checked>) parses to undefined, so flag is what makes <kai-switch checked>, checked="true", and el.checked = true all read as on.
  • Eventsdispatch('kai-change', { checked }) fires a kai-* CustomEvent off the host. Typing defineWebComponent<Props, Events> keeps dispatch honest 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.

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.

src/elements/register.ts
import './switch';
Terminal window
npm run build:api # regenerates element-meta.json, types, React wrappers, llms.txt

That’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.

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.