Appearance
Adding a UIKit Component
This guide walks through creating a new reusable component in the shared UIKit library.
1. Create the Directory
shared/src/uikit/<name>/
├── <name>.vue
├── <name>.scss
├── <name>.stories.ts
└── types/
└── <name>.types.ts2. Define Types
Create shared/src/uikit/<name>/types/<name>.types.ts:
typescript
export const myComponentVariants = ['primary', 'secondary', 'neutral'] as const;
export const myComponentSizes = ['small', 'medium', 'large'] as const;
export type MyComponentVariant = (typeof myComponentVariants)[number];
export type MyComponentSize = (typeof myComponentSizes)[number];Rules:
- Define variants as
constarrays withas const - Derive union types using
(typeof array)[number] - Export both the array (for Storybook controls) and the type
3. Create the Component
Create shared/src/uikit/<name>/<name>.vue:
vue
<template>
<div
:class="[
'gis-<name>',
`gis-<name>--${variant}`,
`gis-<name>--${size}`,
{ 'gis-<name>--disabled': disabled },
]"
v-bind="$attrs"
>
<slot>{{ label }}</slot>
</div>
</template>
<script setup lang="ts">
import type { MyComponentVariant, MyComponentSize } from './types/<name>.types';
withDefaults(
defineProps<{
label?: string;
variant?: MyComponentVariant;
size?: MyComponentSize;
disabled?: boolean;
}>(),
{
label: undefined,
variant: 'primary',
size: 'medium',
disabled: false,
},
);
</script>
<script lang="ts">
export default {
name: 'gis-<name>',
};
</script>Rules:
- Prefix:
gis-in the component name and CSS classes - Two
<script>blocks:<script setup>for logic, named export for DevTools - Use
withDefaults(defineProps<{...}>(), {...})for typed props - Set optional string/object props to
undefined - Use
v-bind="$attrs"on the root element - Dynamic classes use template literals:
`gis-<name>--${variant}` - Conditional classes use object syntax:
{ 'gis-<name>--disabled': disabled }
4. Write Styles
Create shared/src/uikit/<name>/<name>.scss:
scss
.gis-<name> {
@apply inline-flex items-center;
border-radius: 12px;
// Sizes
&--small {
@apply text-xs h-8;
padding: 4px 8px;
}
&--medium {
@apply text-sm h-10;
padding: 8px 12px;
}
&--large {
@apply text-base h-12;
padding: 12px 16px;
}
// Variants
&--primary {
@apply bg-primary-600 text-white;
&:hover:not(:disabled) {
@apply bg-primary-700;
}
&:active:not(:disabled) {
@apply bg-primary-800;
}
}
&--secondary {
@apply bg-neutral-100 text-neutral-700;
&:hover:not(:disabled) {
@apply bg-neutral-200;
}
}
// States
&--disabled {
@apply opacity-50 cursor-not-allowed;
}
// Elements
&__icon {
@apply text-current;
}
&__label {
@apply leading-none;
}
}BEM naming: .gis-<name>, .gis-<name>--<modifier>, .gis-<name>__<element>
Rules:
- Use
@applyfor Tailwind utility classes - Use CSS nesting with
&— no separate selectors - Use
:not(:disabled)for interactive states - Set border-radius with literal values, not
@apply
5. Register the Styles
Add an import to shared/src/uikit/index.scss:
scss
// ... existing imports
@import './<name>/<name>.scss';6. Write Storybook Stories
Create shared/src/uikit/<name>/<name>.stories.ts:
typescript
import type { Meta, StoryObj } from '@storybook/vue3';
import GisMyComponent from './<name>.vue';
import { myComponentVariants, myComponentSizes } from './types/<name>.types';
const meta: Meta<typeof GisMyComponent> = {
title: 'UIKit/MyComponent',
component: GisMyComponent,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: myComponentVariants,
},
size: {
control: 'select',
options: myComponentSizes,
},
label: {
control: 'text',
},
disabled: {
control: 'boolean',
},
},
};
export default meta;
type Story = StoryObj<typeof GisMyComponent>;
export const Primary: Story = {
args: {
label: 'My Component',
variant: 'primary',
size: 'medium',
},
};
export const Secondary: Story = {
args: {
label: 'My Component',
variant: 'secondary',
size: 'medium',
},
};
export const AllVariants: Story = {
render: () => ({
components: { GisMyComponent },
setup: () => ({ variants: myComponentVariants }),
template: `
<div class="flex gap-3 items-center">
<GisMyComponent
v-for="v in variants"
:key="v"
:variant="v"
:label="v"
/>
</div>
`,
}),
};
export const AllSizes: Story = {
render: () => ({
components: { GisMyComponent },
setup: () => ({ sizes: myComponentSizes }),
template: `
<div class="flex gap-3 items-end">
<GisMyComponent
v-for="s in sizes"
:key="s"
:size="s"
:label="s"
/>
</div>
`,
}),
};Rules:
- Title pattern:
UIKit/<ComponentName> - Always include
tags: ['autodocs'] - Use const arrays from types for argType options
- Include individual stories + gallery stories (
AllVariants,AllSizes)
7. Verify in Storybook
bash
pnpm storybookOpen http://localhost:6006 and navigate to your component. Verify:
- All variants render correctly
- All sizes render correctly
- Interactive controls work in the docs tab
- Disabled state works
- The component matches the project's design system (border radius, colors, spacing)
8. Use in a Feature
vue
<script setup lang="ts">
import GisMyComponent from '@uikit/<name>/<name>.vue';
</script>
<template>
<GisMyComponent variant="primary" size="medium" label="Hello" />
</template>