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
| Component | Use Case |
|---|---|
| Dialog | Modal dialogs, confirmations |
| Select | Dropdown selection |
| Checkbox | Boolean inputs |
| Radio Group | Single selection from options |
| Tabs | Tabbed content |
| Toast/Sonner | Notifications |
Medium Priority
| Component | Use Case |
|---|---|
| Card | Content containers |
| Dropdown Menu | Action menus |
| Popover | Floating content |
| Tooltip | Hover information |
| Alert | Status messages |
| Badge | Labels, tags |
Lower Priority
| Component | Use Case |
|---|---|
| Accordion | Collapsible sections |
| Avatar | User images |
| Calendar | Date picking |
| Command | Command palette |
| Pagination | Page navigation |
| Progress | Progress indicators |
| Skeleton | Loading placeholders |
| Slider | Range inputs |
| Table | Data 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.