Custom UI Components
This guide covers creating and managing custom UI components in DeployStack, including extending shadcn/vue components, building new components from scratch, and maintaining consistency across your design system.
Philosophy and Approach
DeployStack follows the shadcn/vue philosophy: components are copied into your codebase and become yours to own and customize. This approach provides several key advantages:
- Full Control: Direct access to component source code for unlimited customization
- No Breaking Changes: Updates don't break your customizations since you own the code
- AI-Friendly: Open code structure allows AI tools to understand and improve components
- Design System Integration: Components naturally fit together using shared design tokens
When to Create Custom Components
Create custom components when:
- Missing Components: The component doesn't exist in shadcn/vue
- Complex Compositions: Combining multiple shadcn components into reusable patterns
- Business Logic: Components specific to DeployStack's domain (e.g., ServerStatusCard, DeploymentProgress)
Component Structure and Organization
Directory Structure
All custom components follow the same structure as shadcn/vue components in src/components/ui/:
src/components/ui/
├── button-group/ # Custom component
│ ├── ButtonGroup.vue
│ ├── ButtonGroupItem.vue
│ └── index.ts
├── file-uploader/ # Custom component
│ ├── FileUploader.vue
│ ├── FileUploaderDropzone.vue
│ ├── FileUploaderList.vue
│ └── index.ts
├── button/ # Existing shadcn component
├── switch/ # Customized shadcn component
└── ...Export Pattern
Always follow the shadcn/vue export pattern:
// src/components/ui/button-group/index.ts
export { default as ButtonGroup } from './ButtonGroup.vue'
export { default as ButtonGroupItem } from './ButtonGroupItem.vue'
// Usage in components
import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group'Component Development Guidelines
1. Design Token Integration
✅ Use shadcn/vue design tokens for consistency:
<template>
<div :class="[
'rounded-md border bg-card text-card-foreground shadow-sm',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
]">
<!-- Uses CSS variables from shadcn theme -->
</div>
</template>❌ Avoid hard-coded colors:
<template>
<!-- Don't do this -->
<div class="bg-blue-500 text-white border-gray-300">
Content
</div>
</template>2. Prop Interface Standards
Follow consistent prop patterns across all components:
<script setup lang="ts">
interface Props {
// Standard shadcn props
variant?: 'default' | 'destructive' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
// Component-specific props
orientation?: 'horizontal' | 'vertical'
allowMultiple?: boolean
// Event handlers
onSelectionChange?: (value: string[]) => void
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
size: 'md',
disabled: false,
orientation: 'horizontal',
allowMultiple: false
})
</script>3. Class Variance Authority (CVA)
Use CVA for complex styling variants:
<script setup lang="ts">
import { cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonGroupVariants = cva(
// Base classes
'inline-flex items-center',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
outline: 'border border-input bg-background',
ghost: 'hover:bg-accent hover:text-accent-foreground'
},
size: {
sm: 'h-8 px-2 text-xs',
md: 'h-9 px-3 text-sm',
lg: 'h-10 px-4'
},
orientation: {
horizontal: 'flex-row [&>*:not(:first-child):not(:last-child)]:rounded-none [&>*:first-child]:rounded-r-none [&>*:last-child]:rounded-l-none',
vertical: 'flex-col [&>*:not(:first-child):not(:last-child)]:rounded-none [&>*:first-child]:rounded-b-none [&>*:last-child]:rounded-t-none'
}
},
defaultVariants: {
variant: 'default',
size: 'md',
orientation: 'horizontal'
}
}
)
const containerClass = computed(() =>
cn(buttonGroupVariants({ variant: props.variant, size: props.size, orientation: props.orientation }))
)
</script>4. Accessibility Requirements
Ensure all custom components meet accessibility standards:
<script setup lang="ts">
import { useId } from 'vue'
const id = useId()
const describedBy = `${id}-description`
</script>
<template>
<div
role="group"
:aria-labelledby="id"
:aria-describedby="describedBy"
>
<div :id="id" class="sr-only">{{ t('components.buttonGroup.label') }}</div>
<div :id="describedBy" class="sr-only">{{ t('components.buttonGroup.description') }}</div>
<slot />
</div>
</template>Component Types and Examples
1. Composite Components
Components that combine multiple shadcn components:
<!-- src/components/ui/server-card/ServerCard.vue -->
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
interface ServerCardProps {
server: {
id: string
name: string
status: 'running' | 'stopped' | 'error'
category: string
description?: string
}
onManage: (serverId: string) => void
onDeploy: (serverId: string) => void
}
const props = defineProps<ServerCardProps>()
const statusVariant = computed(() => {
switch (props.server.status) {
case 'running': return 'default'
case 'stopped': return 'secondary'
case 'error': return 'destructive'
default: return 'outline'
}
})
</script>
<template>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">{{ server.name }}</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="h-8 w-8 p-0">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="props.onManage(server.id)">
{{ t('actions.manage') }}
</DropdownMenuItem>
<DropdownMenuItem @click="props.onDeploy(server.id)">
{{ t('actions.deploy') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
<CardContent>
<div class="flex items-center justify-between">
<Badge :variant="statusVariant">{{ server.status }}</Badge>
<Badge variant="outline" class="font-mono text-xs">{{ server.category }}</Badge>
</div>
<p v-if="server.description" class="text-sm text-muted-foreground mt-2">
{{ server.description }}
</p>
</CardContent>
</Card>
</template>2. Utility Components
Reusable patterns with business logic:
<!-- src/components/ui/deployment-status/DeploymentStatus.vue -->
<script setup lang="ts">
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { CheckCircle, AlertCircle, Clock, XCircle } from 'lucide-vue-next'
interface DeploymentStatusProps {
status: 'pending' | 'running' | 'success' | 'failed'
progress?: number
message?: string
showProgress?: boolean
}
const props = withDefaults(defineProps<DeploymentStatusProps>(), {
progress: 0,
showProgress: true
})
const statusConfig = computed(() => {
switch (props.status) {
case 'pending':
return { icon: Clock, variant: 'secondary' as const, color: 'text-yellow-600' }
case 'running':
return { icon: Clock, variant: 'default' as const, color: 'text-blue-600' }
case 'success':
return { icon: CheckCircle, variant: 'default' as const, color: 'text-green-600' }
case 'failed':
return { icon: XCircle, variant: 'destructive' as const, color: 'text-red-600' }
default:
return { icon: AlertCircle, variant: 'outline' as const, color: 'text-muted-foreground' }
}
})
</script>
<template>
<div class="space-y-2">
<div class="flex items-center gap-2">
<component
:is="statusConfig.icon"
:class="['h-4 w-4', statusConfig.color]"
/>
<Badge :variant="statusConfig.variant">
{{ t(`deployment.status.${status}`) }}
</Badge>
</div>
<Progress
v-if="showProgress && status === 'running'"
:value="progress"
class="h-2"
/>
<p v-if="message" class="text-sm text-muted-foreground">
{{ message }}
</p>
</div>
</template>3. Form Components
Specialized form inputs that extend basic components:
<!-- src/components/ui/icon-selector/IconSelector.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Badge } from '@/components/ui/badge'
import { Check } from 'lucide-vue-next'
interface IconSelectorProps {
modelValue?: string
placeholder?: string
disabled?: boolean
}
const props = withDefaults(defineProps<IconSelectorProps>(), {
placeholder: 'Select icon...',
disabled: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = ref(false)
// Available icons - in real implementation, this would be imported from a constants file
const availableIcons = [
{ value: '🚀', label: 'Rocket' },
{ value: '⚡', label: 'Lightning' },
{ value: '🔧', label: 'Wrench' },
{ value: '📊', label: 'Chart' },
{ value: '🗄️', label: 'Database' }
]
const selectedIcon = computed(() =>
availableIcons.find(icon => icon.value === props.modelValue)
)
function selectIcon(iconValue: string) {
emit('update:modelValue', iconValue)
open.value = false
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
:disabled="disabled"
class="w-full justify-between"
>
<div v-if="selectedIcon" class="flex items-center gap-2">
<span>{{ selectedIcon.value }}</span>
<span>{{ selectedIcon.label }}</span>
</div>
<span v-else class="text-muted-foreground">{{ placeholder }}</span>
<ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-full p-0">
<Command>
<CommandInput :placeholder="t('components.iconSelector.search')" />
<CommandList>
<CommandEmpty>{{ t('components.iconSelector.noResults') }}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="icon in availableIcons"
:key="icon.value"
:value="icon.value"
@select="selectIcon(icon.value)"
>
<Check
:class="[
'mr-2 h-4 w-4',
modelValue === icon.value ? 'opacity-100' : 'opacity-0'
]"
/>
<span class="mr-2">{{ icon.value }}</span>
{{ icon.label }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>Customizing Existing shadcn Components
Replacing Components
To customize an existing shadcn component, simply modify the files in src/components/ui/[component-name]/:
<!-- src/components/ui/switch/Switch.vue - Custom implementation -->
<script setup lang="ts">
interface Props {
checked?: boolean
disabled?: boolean
size?: 'sm' | 'md' | 'lg'
variant?: 'default' | 'success' | 'warning'
}
const props = withDefaults(defineProps<Props>(), {
checked: false,
disabled: false,
size: 'md',
variant: 'default'
})
const emit = defineEmits<{
'update:checked': [value: boolean]
}>()
const sizeClasses = {
sm: 'h-4 w-7',
md: 'h-6 w-11',
lg: 'h-8 w-14'
}
const variantClasses = {
default: 'data-[state=checked]:bg-primary',
success: 'data-[state=checked]:bg-green-600',
warning: 'data-[state=checked]:bg-yellow-600'
}
</script>
<template>
<button
type="button"
role="switch"
:aria-checked="checked"
:data-state="checked ? 'checked' : 'unchecked'"
:disabled="disabled"
:class="[
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=unchecked]:bg-input',
sizeClasses[size],
variantClasses[variant]
]"
@click="!disabled && emit('update:checked', !checked)"
>
<span
:data-state="checked ? 'checked' : 'unchecked'"
:class="[
'pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform',
size === 'sm' ? 'h-3 w-3 data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0' : '',
size === 'md' ? 'h-4 w-4 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0' : '',
size === 'lg' ? 'h-6 w-6 data-[state=checked]:translate-x-6 data-[state=unchecked]:translate-x-0' : ''
]"
/>
</button>
</template>Documentation Standards
Component Documentation
Each custom component should include comprehensive documentation:
<!-- src/components/ui/button-group/ButtonGroup.vue -->
<!--
@component ButtonGroup
@description A group of related buttons that can function as a single unit or allow multiple selections.
@example
<ButtonGroup v-model="selected" variant="outline">
<ButtonGroupItem value="left">Left</ButtonGroupItem>
<ButtonGroupItem value="center">Center</ButtonGroupItem>
<ButtonGroupItem value="right">Right</ButtonGroupItem>
</ButtonGroup>
@props
- variant: Visual style variant ('default' | 'outline' | 'ghost')
- size: Size variant ('sm' | 'md' | 'lg')
- orientation: Layout orientation ('horizontal' | 'vertical')
- allowMultiple: Whether multiple items can be selected
- disabled: Whether the entire group is disabled
@emits
- update:modelValue: Emitted when selection changes
- selectionChange: Emitted with detailed selection info
@slots
- default: ButtonGroupItem components
@accessibility
- Uses proper ARIA attributes for group semantics
- Supports keyboard navigation between items
- Maintains focus management
-->README Documentation
Create a README.md for complex component groups:
# Button Group Component
A composable button group component for related actions.
## Features
- Single or multiple selection modes
- Horizontal and vertical orientations
- Full keyboard accessibility
- Consistent with shadcn/vue design system
## Usage
```vue
<script setup>
import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group'
</script>
<template>
<ButtonGroup v-model="selection" allow-multiple>
<ButtonGroupItem value="bold">Bold</ButtonGroupItem>
<ButtonGroupItem value="italic">Italic</ButtonGroupItem>
<ButtonGroupItem value="underline">Underline</ButtonGroupItem>
</ButtonGroup>
</template>API Reference
ButtonGroup Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | string[] | undefined | Selected value(s) |
variant | 'default' | 'outline' | 'ghost' | 'default' | Visual style |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Layout direction |
Maintenance and Updates
Migration Strategy
When shadcn/vue adds a component you've built custom:
- Evaluate: Compare features and API design
- Migrate: If shadcn version is better, plan migration
- Extend: If your version has unique features, consider contributing back
- Document: Update migration guides for the team
Version Control
- Tag component versions in commit messages:
feat(ui): add ButtonGroup component v1.0 - Document breaking changes in component README
- Maintain changelog for significant UI component updates
For questions about custom components, refer to the UI Design System documentation or reach out to the frontend team.