Rules every component in the kit must follow. Read this before adding a new component.
src/components/Button/
├── UidButton.vue # template + script setup
├── UidButton.css # styles (imported from <script setup>)
├── UidButton.stories.ts # Storybook
├── UidButton.spec.ts # Vitest + @vue/test-utils
└── index.ts # component + types re-export
| Entity | Prefix | Example |
|---|---|---|
| Component name | Uid (PascalCase) |
UidButton, UidInputField |
| Root CSS class | uid- (kebab-case) |
.uid-button, .uid-input-field |
| Modifier class | BEM double-dash | .uid-button--primary, .uid-button--size-sm |
| Element class | BEM double-underscore | .uid-button__icon |
| Component CSS variable | --uid-{component}-* |
--uid-button-bg |
| Props type | {Component}Props |
UidButtonProps |
| Enum variant type | {Component}{Aspect} |
UidButtonVariant, UidButtonSize |
- Always typed. Defaults via
withDefaults. - Sizes — named scale:
'sm' | 'md' | 'lg'. No raw numbers. - Variants — finite union:
'primary' | 'secondary' | 'ghost' | 'danger'. - No
any, noObjectprops without an interface.
Form-input components (UidInput, UidSelect, UidCheckbox, …) extend the common ValidatableProps:
interface ValidatableProps {
rules?: RuleInput // 'required|email' | RuleFn | (string | RuleFn)[]
name?: string // field name — needed for cross-field rules in useForm
label?: string // substituted into error messages
}See validation.
export type UidButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
export type UidButtonSize = 'sm' | 'md' | 'lg'
export interface UidButtonProps {
variant?: UidButtonVariant
size?: UidButtonSize
disabled?: boolean
loading?: boolean
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<UidButtonProps>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
type: 'button',
})- Names — kebab-case, no
onprefix:click,update:modelValue,change,close. - Typed via
defineEmitsgeneric.
const emit = defineEmits<{
click: [event: MouseEvent]
'update:modelValue': [value: string]
}>()- Default slot — main content.
- Named slots — for optional pieces:
prepend,append,icon,header,footer. - Typed via
defineSlots(Vue 3.3+).
defineSlots<{
default(): unknown
prepend?(): unknown
append?(): unknown
}>()- Use
defineModel()(Vue 3.4+) — shorter and type-safer than theprop+emitpair.
const model = defineModel<string>()The radius scale is intentionally tiny — five values, most components use one.
| Component | Radius |
|---|---|
UidButton, UidInput, UidSelect, UidTextarea |
--uid-radius-md (6px) |
UidDropdown, UidMenu, UidAlert, UidToast |
--uid-radius-md (6px) |
UidCard, UidModal, UidDrawer, UidPopover |
--uid-radius-lg (10px) |
UidBadge, UidTag, UidTooltip, UidCheckbox |
--uid-radius-sm (3px) |
UidAvatar |
--uid-radius-full (9999px) |
| Tables, dividers, code blocks | --uid-radius-none (0) |
When in doubt — md. full is intentional, never default.
Every interactive component supports three sizes via size: 'sm' | 'md' | 'lg'.
| Size | Height | Padding-x | Font | Icon |
|---|---|---|---|---|
sm |
32px (--uid-size-sm) |
--uid-space-sm (8px) |
--uid-font-size-sm (14px) |
16px |
md |
40px (--uid-size-md) |
--uid-space-md (16px) |
--uid-font-size-md (16px) |
20px |
lg |
48px (--uid-size-lg) |
--uid-space-lg (24px) |
--uid-font-size-lg (18px) |
20px |
Height uses min-height, not height — multiline content can grow the element.
Components without text (icons, avatars) use width + height directly.
Icons in lg stay 20px — bumping to 24px throws off visual balance with the text.
Component.cssis imported from<script setup>:import './UidButton.css'.- No
scoped. Isolation via.uid-{component}prefix + BEM. - All knobs go through local CSS variables that fall back to semantic tokens (see tokens).
- States — via attributes or modifier classes:
:hover,:focus-visible,:active— pseudo-classes.disabled—disabledattribute or[aria-disabled="true"], not a class.loading,selected,expanded—data-*attributes or modifier classes.
- Focus ring — always
:focus-visible(not:focus). One uniform style across the kit:.uid-button:focus-visible { outline: 2px solid var(--uid-accent); outline-offset: 2px; border-radius: inherit; } .uid-input--error:focus-visible { outline-color: var(--uid-danger); }
.uid-button {
--uid-button-bg: var(--uid-accent);
--uid-button-bg-hover: var(--uid-accent-hover);
--uid-button-color: var(--uid-text-on-accent);
--uid-button-radius: var(--uid-radius-md);
--uid-button-height: var(--uid-size-md);
--uid-button-padding-x: var(--uid-space-md);
--uid-button-padding-y: var(--uid-space-sm);
display: inline-flex;
align-items: center;
gap: var(--uid-space-xs);
min-height: var(--uid-button-height);
padding: var(--uid-button-padding-y) var(--uid-button-padding-x);
border: 0;
border-radius: var(--uid-button-radius);
background: var(--uid-button-bg);
color: var(--uid-button-color);
font: inherit;
cursor: pointer;
transition: background var(--uid-duration-fast) var(--uid-ease-out);
}
.uid-button:hover { background: var(--uid-button-bg-hover); }
.uid-button:focus-visible {
outline: 2px solid var(--uid-accent);
outline-offset: 2px;
}
.uid-button[disabled],
.uid-button[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
.uid-button--secondary {
--uid-button-bg: var(--uid-surface-raised);
--uid-button-bg-hover: var(--uid-border-subtle);
--uid-button-color: var(--uid-text-primary);
}
.uid-button--sm { --uid-button-height: var(--uid-size-sm); --uid-button-padding-x: var(--uid-space-sm); font-size: var(--uid-font-size-sm); }
.uid-button--lg { --uid-button-height: var(--uid-size-lg); --uid-button-padding-x: var(--uid-space-lg); font-size: var(--uid-font-size-lg); }Components with a semantic -hover token use it directly:
.uid-button:hover { background: var(--uid-button-bg-hover); }Surfaces without a -hover token (menu items, table rows, nav items) use a translucent overlay:
.uid-menu-item:hover { background: rgb(0 0 0 / 0.04); }
:root[data-theme='dark'] .uid-menu-item:hover { background: rgb(255 255 255 / 0.06); }Always transition: <prop> var(--uid-duration-fast) var(--uid-ease-out).
.uid-button:active { transform: scale(0.98); }.uid-button[disabled],
.uid-button[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}pointer-events: none is avoided — it kills tooltips on disabled elements that explain why they're disabled.
For form controls (UidInput, UidSelect, UidTextarea):
.uid-input--error { border-color: var(--uid-danger); }
.uid-input--error:focus-visible { outline-color: var(--uid-danger); }An error always comes with text — colour alone isn't enough for a11y.
.uid-button[data-loading='true'] {
opacity: 0.7;
cursor: wait;
pointer-events: none;
}Minimum every component must meet:
- Native semantic elements first (
<button>,<input>,<dialog>). If there's no native element, use the rightrole. - All interactive elements reachable via keyboard (Tab/Shift+Tab, Enter/Space, Esc to close).
- States surfaced via ARIA:
aria-disabled,aria-expanded,aria-selected,aria-invalid,aria-describedby. - Form controls have a label (slot, prop,
aria-label/aria-labelledby). - Focus ring visible — never strip
outlinewithout replacing it via:focus-visible. - Don't use colour alone to convey meaning (error = colour + text/icon, not just a red border).
Storybook ships with @storybook/addon-a11y. A component must pass it without critical violations before the PR.
Unit tests are required for:
- prop → classes/attributes
- event emission
- v-model two-way binding
- a11y invariants (e.g.
aria-disabledwhendisabled)
Visual cases live in Storybook.
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import UidButton from './UidButton.vue'
describe('UidButton', () => {
it('emits click', async () => {
const wrapper = mount(UidButton, { slots: { default: 'OK' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
it('does not emit click when disabled', async () => {
const wrapper = mount(UidButton, { props: { disabled: true } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeUndefined()
})
})One .stories.ts per component, with at minimum:
Default— bare, no props.- One story per
variant. - One story per
size(or a singleSizesshowing them side-by-side). Disabled,Loading— if applicable.Playground— all args asargTypesto poke at.
Story template:
import type { Meta, StoryObj } from '@storybook/vue3'
import UidButton from './UidButton.vue'
const meta: Meta<typeof UidButton> = {
title: 'Inputs/Button',
component: UidButton,
tags: ['autodocs'],
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger'] },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
disabled: { control: 'boolean' },
loading: { control: 'boolean' },
},
}
export default meta
type Story = StoryObj<typeof UidButton>
export const Playground: Story = {
args: { variant: 'primary', size: 'md' },
render: (args) => ({
components: { UidButton },
setup: () => ({ args }),
template: `<UidButton v-bind="args">Button</UidButton>`,
}),
}- Folder layout matches the convention above.
- Props/Events/Slots typed; JSDoc where the meaning isn't obvious from the name.
- Styles use semantic tokens or local variables fallbacking to them only.
- Works in both light and dark in Storybook.
- Storybook covers Default + Variants + States.
-
addon-a11yshows no critical violations. - Unit tests cover props → render and events.
- Component exported from
src/index.ts. - Changeset added (
pnpm changeset).
See also the template at templates/COMPONENT.md.