Skip to main content
Version: v1 (Current)

Adding shadcn-vue Components

This guide covers how to add new UI primitives from shadcn-vue to the UI Kit.

Overview

The UI Kit uses shadcn-vue for accessible, unstyled UI primitives. These components are built on Radix Vue and styled with Tailwind CSS.

Using the CLI

Add a Component

The fastest way to add a shadcn component:

npx shadcn-vue@latest add <component-name>

Example: Adding Dialog

npx shadcn-vue@latest add dialog

This creates:

src/components/ui/dialog/
├── Dialog.vue
├── DialogContent.vue
├── DialogHeader.vue
├── DialogTitle.vue
├── DialogDescription.vue
├── DialogFooter.vue
└── index.ts

Add Multiple Components

npx shadcn-vue@latest add dialog alert-dialog sheet

List Available Components

npx shadcn-vue@latest add --help

Or visit shadcn-vue components.

Post-Installation Steps

After adding a component with the CLI, complete these steps:

1. Verify Installation

Check the files were created:

ls src/components/ui/dialog/

2. Export from Main Index

Add exports to src/index.ts:

// src/index.ts

// Existing exports
export { Button, buttonVariants } from './components/ui/button'
export { Input } from './components/ui/input'

// New: Dialog exports
export {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
DialogClose
} from './components/ui/dialog'

3. Write Tests

Create Dialog.test.ts:

// src/components/ui/dialog/Dialog.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogTrigger
} from '.'

describe('Dialog', () => {
it('renders trigger button', () => {
const wrapper = mount(Dialog, {
slots: {
default: `
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Title</DialogTitle>
</DialogHeader>
</DialogContent>
`
},
global: {
components: {
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle
}
}
})

expect(wrapper.text()).toContain('Open')
})

it('opens when trigger is clicked', async () => {
const wrapper = mount(Dialog, {
slots: {
default: `
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogTitle>Test Dialog</DialogTitle>
</DialogContent>
`
},
global: {
components: {
DialogTrigger,
DialogContent,
DialogTitle
}
}
})

await wrapper.find('button').trigger('click')
// Dialog content should be visible
expect(document.body.innerHTML).toContain('Test Dialog')
})

it('has accessible title', async () => {
const wrapper = mount(Dialog, {
props: { open: true },
slots: {
default: `
<DialogContent>
<DialogTitle>Accessible Title</DialogTitle>
</DialogContent>
`
},
global: {
components: { DialogContent, DialogTitle }
}
})

const title = document.querySelector('[role="dialog"] h2')
expect(title?.textContent).toBe('Accessible Title')
})
})

4. Write Stories

Create Dialog.stories.ts:

// src/components/ui/dialog/Dialog.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
DialogClose
} from '.'
import { Button } from '../button'
import { Input } from '../input'

const meta: Meta<typeof Dialog> = {
title: 'UI/Dialog',
component: Dialog,
tags: ['autodocs'],
}

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
render: () => ({
components: {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogTrigger,
Button
},
template: `
<Dialog>
<DialogTrigger as-child>
<Button variant="outline">Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>
This is the dialog description. It provides context for the dialog.
</DialogDescription>
</DialogHeader>
<p>Dialog content goes here.</p>
</DialogContent>
</Dialog>
`
})
}

export const WithForm: Story = {
render: () => ({
components: {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
DialogClose,
Button,
Input
},
setup() {
const name = ref('')
const email = ref('')
return { name, email }
},
template: `
<Dialog>
<DialogTrigger as-child>
<Button>Edit Profile</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you're done.
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid grid-cols-4 items-center gap-4">
<label for="name" class="text-right">Name</label>
<Input id="name" v-model="name" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<label for="email" class="text-right">Email</label>
<Input id="email" v-model="email" class="col-span-3" />
</div>
</div>
<DialogFooter>
<DialogClose as-child>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
`
})
}

export const Controlled: Story = {
render: () => ({
components: {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
Button
},
setup() {
const open = ref(false)
return { open }
},
template: `
<div>
<p class="mb-4">Dialog is {{ open ? 'open' : 'closed' }}</p>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<Button>Open Controlled Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Controlled Dialog</DialogTitle>
</DialogHeader>
<p>This dialog's open state is controlled externally.</p>
<Button @click="open = false">Close from inside</Button>
</DialogContent>
</Dialog>
</div>
`
})
}

export const AlertStyle: Story = {
render: () => ({
components: {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
DialogClose,
Button
},
template: `
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive">Delete Account</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose as-child>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive">Delete Account</Button>
</DialogFooter>
</DialogContent>
</Dialog>
`
})
}

5. Build and Test

# Run tests
npm test

# Check build
npm run build

# Verify in Storybook
npm run storybook

Manual Installation

If the CLI doesn't work or you need customizations:

1. Install Dependencies

Check if the component needs additional dependencies:

# Example: Calendar needs date utilities
npm install @internationalized/date

2. Copy from shadcn-vue

Visit the shadcn-vue GitHub and copy the component source.

3. Adapt the Component

Ensure imports use the correct paths:

// Change this:
import { cn } from '@/lib/utils'

// To match your project structure (should already be correct):
import { cn } from '@/lib/utils'

Common Components to Add

High Priority

ComponentUse Case
DialogModal dialogs, confirmations
SelectDropdown selection
CheckboxBoolean inputs
Radio GroupSingle selection from options
TabsTabbed content
Toast/SonnerNotifications

Medium Priority

ComponentUse Case
CardContent containers
Dropdown MenuAction menus
PopoverFloating content
TooltipHover information
AlertStatus messages
BadgeLabels, tags

Lower Priority

ComponentUse Case
AccordionCollapsible sections
AvatarUser images
CalendarDate picking
CommandCommand palette
PaginationPage navigation
ProgressProgress indicators
SkeletonLoading placeholders
SliderRange inputs
TableData tables

Troubleshooting

CLI Errors

"Cannot find components.json"

Ensure you're in the project root and components.json exists:

ls components.json

"Component already exists"

The component is already installed. Check src/components/ui/.

Build Errors

Missing peer dependency

Install the required package:

npm install <package-name>

Type errors

Ensure TypeScript paths are configured:

// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

Styling Issues

Component not styled

Ensure styles are imported:

import '@gxp-dev/uikit/styles'

CSS variables not applied

Check that CSS variables are defined in :root.