Skip to content

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

2. 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 const arrays with as 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 @apply for 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 storybook

Open 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>