Component Libraries
On this page 25
Build reusable STX component libraries that work across projects with Stacks.
Component Architecture
File Structure
Organize components by feature or complexity:
components/
├── Button/
│ ├── Button.stx # Main component
│ ├── Button.test.ts # Tests
│ └── index.ts # Export
├── Modal/
│ ├── Modal.stx
│ ├── ModalHeader.stx
│ ├── ModalBody.stx
│ ├── ModalFooter.stx
│ └── index.ts
├── Form/
│ ├── Input.stx
│ ├── Select.stx
│ ├── Checkbox.stx
│ └── index.ts
└── index.ts # Main export
Single File Components
Use STX SFCs with TypeScript:
<!-- components/Card/Card.stx -->
<template
<divclass="">
<headerv-if=""class="">
<slotname="">
<h3class="">{{ title }}</h3>
</slot>
</header>
<divclass="">
<slot />
</div>
<footerv-if=""class="">
<slotname="" />
</footer>
</div>
</template>
<script
import { computed } from '@stacksjs/stx'
export interface CardProps {
title?: string
variant?: 'default' | 'outlined' | 'elevated'
padding?: 'none' | 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<CardProps>(), {
variant: 'default',
padding: 'md',
})
const cardClasses = computed(() => [
'card',
`card--${props.variant}`,
`card--padding-${props.padding}`,
])
</script>
<stylescoped
.card {
border-radius: 0.5rem;
background: white;
}
.card--default {
border: 1px solid #e5e7eb;
}
.card--outlined {
border: 2px solid #3b82f6;
}
.card--elevated {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.card--padding-none .card-body { padding: 0; }
.card--padding-sm .card-body { padding: 0.5rem; }
.card--padding-md .card-body { padding: 1rem; }
.card--padding-lg .card-body { padding: 1.5rem; }
.card-header {
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.card-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.card-footer {
padding: 1rem;
border-top: 1px solid #e5e7eb;
}
</style>
Props Design
Type-Safe Props
Always define prop types:
// Types can be exported for consumers
export interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
fullWidth?: boolean
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
fullWidth: false,
})
Prop Validation
Add runtime validation for complex props:
<script
import { computed, warn } from '@stacksjs/stx'
export interface InputProps {
type?: 'text' | 'email' | 'password' | 'number' | 'tel'
modelValue?: string | number
min?: number
max?: number
}
const props = withDefaults(defineProps<InputProps>(), {
type: 'text',
})
// Validate props
if (props.type === 'number') {
if (props.min ! undefined && props.max ! undefined && props.min > props.max) {
warn('Input: min cannot be greater than max')
}
}
</script>
Events
Typed Events
Define event types explicitly:
<script
const emit = defineEmits<{
click: [event: MouseEvent]
change: [value: string]
submit: [data: FormData]
'update:modelValue': [value: string]
}>()
function handleClick(event: MouseEvent) {
if (!props.disabled) {
emit('click', event)
}
}
</script>
v-model Support
Support two-way binding:
<!-- components/Input.stx -->
<template
<input
:value="modelValue"
:type="type"
:placeholder="placeholder"
@input="handleInput"
@focus="emit('focus', $event)"
@blur="emit('blur', $event)"
/>
</template>
<script
export interface InputProps {
modelValue?: string
type?: string
placeholder?: string
}
const props = withDefaults(defineProps<InputProps>(), {
modelValue: '',
type: 'text',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
focus: [event: FocusEvent]
blur: [event: FocusEvent]
}>()
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
Slots
Named Slots
Provide flexibility with slots:
<!-- components/Modal.stx -->
<template
<Teleportto="">
<divv-if=""class=""clickself="">
<divclass=""class="">
<headerclass="">
<slotname="">
<h2>{{ title }}</h2>
</slot>
<buttonv-if=""class=""click="">
×
</button>
</header>
<divclass="">
<slot />
</div>
<footerv-if=""class="">
<slotname=""close="" />
</footer>
</div>
</div>
</Teleport>
</template>
<script
export interface ModalProps {
modelValue: boolean
title?: string
size?: 'sm' | 'md' | 'lg' | 'full'
closable?: boolean
}
const props = withDefaults(defineProps<ModalProps>(), {
size: 'md',
closable: true,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', false)
}
</script>
Scoped Slots
Pass data to slot content:
<!-- components/DataTable.stx -->
<template
<tableclass="">
<thead>
<tr>
<thv-for=""key="">
<slotname=""column="">
{{ column.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<trv-for=""key="">
<tdv-for=""key="">
<slotname=""row=""value="">
{{ row[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<script
export interface Column {
key: string
label: string
}
export interface DataTableProps<T> {
columns: Column[]
data: T[]
}
defineProps<DataTablePropsT>>()
</script>
Composables
Extracting Logic
Move reusable logic to composables:
// composables/useClickOutside.ts
import { onMounted, onUnmounted, type Ref } from '@stacksjs/stx'
export function useClickOutside(
elementRef: Ref<HTMLElement | null>,
callback: () => void
) {
function handler(event: MouseEvent) {
if (elementRef.value && !elementRef.value.contains(event.target as Node)) {
callback()
}
}
onMounted(() => {
document.addEventListener('click', handler)
})
onUnmounted(() => {
document.removeEventListener('click', handler)
})
}
Using in Components
<!-- components/Dropdown.stx -->
<template
<divref=""class="">
<buttonclick="">
<slotname="">Toggle</slot>
</button>
<divv-if=""class="">
<slot />
</div>
</div>
</template>
<script
import { ref } from '@stacksjs/stx'
import { useClickOutside } from '../composables/useClickOutside'
const dropdownRef = ref<HTMLElementnull>(null)
const isOpen = ref(false)
function toggle() {
isOpen.value = !isOpen.value
}
function close() {
isOpen.value = false
}
useClickOutside(dropdownRef, close)
</script>
Styling
CSS Variables
Use CSS custom properties for theming:
<style
.button {
--button-bg: var(--color-primary, #3b82f6);
--button-color: var(--color-primary-contrast, white);
--button-radius: var(--radius-md, 0.375rem);
--button-padding-x: var(--spacing-4, 1rem);
--button-padding-y: var(--spacing-2, 0.5rem);
background-color: var(--button-bg);
color: var(--button-color);
border-radius: var(--button-radius);
padding: var(--button-padding-y) var(--button-padding-x);
}
</style>
Tailwind Support
Optionally support Tailwind:
<template
<button
:class="[
'inline-flex items-center justify-center rounded-md font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
variantClasses,
sizeClasses,
]"
>
<slot />
</button>
</template>
<script
import { computed } from '@stacksjs/stx'
const props = defineProps<{
variant?: 'primary' | 'secondary' | 'outline'
size?: 'sm' | 'md' | 'lg'
}>()
const variantClasses = computed(() => ({
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-500 text-white hover:bg-gray-600',
outline: 'border-2 border-blue-500 text-blue-500 hover:bg-blue-50',
})[props.variant || 'primary'])
const sizeClasses = computed(() => ({
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
})[props.size || 'md'])
</script>
Accessibility
ARIA Attributes
Include proper accessibility:
<!-- components/Alert.stx -->
<template
<div
:class="['alert', `alert--${variant}`]"
role="alert"
:aria-live="variant === 'error' ? 'assertive' : 'polite'"
>
<spanclass=""aria-hidden="">
<slotname="">{{ icon }}</slot>
</span>
<divclass="">
<slot />
</div>
<button
v-if="dismissible"
class="alert-dismiss"
aria-label="Dismiss alert"
@click="emit('dismiss')"
>
×
</button>
</div>
</template>
Keyboard Navigation
Support keyboard users:
<script
function handleKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault()
toggle()
break
case 'Escape':
close()
break
case 'ArrowDown':
event.preventDefault()
focusNext()
break
case 'ArrowUp':
event.preventDefault()
focusPrevious()
break
}
}
</script>
Testing Components
Unit Tests
// components/Button/Button.test.ts
import { describe, it, expect } from 'bun:test'
import { mount } from '@stacksjs/stx/testing'
import Button from './Button.stx'
describe('Button', () => {
it('renders slot content', () => {
const wrapper = mount(Button, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toBe('Click me')
})
it('applies variant class', () => {
const wrapper = mount(Button, {
props: { variant: 'danger' },
})
expect(wrapper.classes()).toContain('button--danger')
})
it('is disabled when prop is set', () => {
const wrapper = mount(Button, {
props: { disabled: true },
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('shows loading spinner', () => {
const wrapper = mount(Button, {
props: { loading: true },
})
expect(wrapper.find('.spinner').exists()).toBe(true)
})
})
Best Practices
- Single responsibility - Each component does one thing
- Consistent API - Similar components have similar props
- Sensible defaults - Work out of the box
- Type everything - Full TypeScript coverage
- Document inline - Use
<docs>blocks - Test thoroughly - Unit tests for all features
Related
- Getting Started - Library setup
- Functions - Function libraries
- Publishing - Publishing workflow