Components
On this page 29
Stacks provides a powerful component system for building reusable UI elements. Components in Stacks use a STX-like syntax with TypeScript support, compiled through the Stacks templating engine.
Overview
The component system helps you:
- Build reusable UI - Create modular, composable interfaces
- Maintain consistency - Shared components ensure UI uniformity
- Type-safe props - Full TypeScript support for component APIs
- Scoped styles - CSS isolated to components
Quick Start
Creating a Component
Components live in resources/components/:
<!-- resources/components/Button.stx -->
<script
interface Props {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}
const props = withDefaults(defineProps<Props(), {
variant: 'primary',
size: 'md',
disabled: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
</script
<template
<button
:class="['btn', `btn-${props.variant}`, `btn-${props.size}`]"
:disabled="props.disabled"
@click="emit('click', $event)"
>
<slot
</button
</template
<stylescoped
.btn {
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
.btn-md { padding: 0.5rem 1rem; font-size: 1rem; }
.btn-lg { padding: 0.75rem 1.5rem; font-size: 1.125rem; }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style
Using Components
<!-- resources/views/pages/index.stx -->
<template
<divclass=""
<Buttonvariant=""click=""
Click Me
</Button
<Buttonvariant=""size=""
Small Button
</Button
<Buttonvariant=""disabled=""
Delete
</Button
</div
</template
<script
import { ref } from '@stacksjs/stx'
const isLoading = ref(false)
function handleClick(event: MouseEvent) {
console.log('Button clicked!', event)
}
</script
Component Props
Defining Props
<script
// Simple props
const props = defineProps<{
title: string
count: number
active: boolean
}>()
// With defaults
interface Props {
title: string
count?: number
active?: boolean
}
const props = withDefaults(defineProps<Props(), {
count: 0,
active: false,
})
</script
Complex Props
<script
interface User {
id: number
name: string
email: string
avatar?: string
}
interface Props {
user: User
permissions: string[]
config: Record<stringunknown
}
const props = defineProps<Props()
</script
<template
<divclass=""
<imgsrc=""
<h3{{ props.user.name }}</h3
<p{{ props.user.email }}</p
</div
</template
Prop Validation
<script
const props = defineProps({
// Required string
title: {
type: String,
required: true,
},
// Number with default
count: {
type: Number,
default: 0,
},
// Enum-like validation
status: {
type: String,
validator: (value: string) => {
return ['pending', 'active', 'completed'].includes(value)
},
},
})
</script
Component Events
Emitting Events
<script
// Define emitted events with types
const emit = defineEmits<{
'update:modelValue': [value: string]
'submit': [data: FormData]
'cancel': []
}>()
function handleSubmit() {
const formData = new FormData()
emit('submit', formData)
}
function handleCancel() {
emit('cancel')
}
</script
<template
<formsubmitprevent=""
<input
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<buttontype=""Submit</button
<buttontype=""click=""Cancel</button
</form
</template
v-model Support
<!-- Input.stx -->
<script
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script
<template
<input
:value="props.modelValue"
:placeholder="props.placeholder"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template
<!-- Usage -->
<template
<Inputv-model=""placeholder=""
</template
Slots
Default Slot
<!-- Card.stx -->
<template
<divclass=""
<slot
</div
</template
<!-- Usage -->
<Card
<h2Card Title</h2
<pCard content goes here</p
</Card
Named Slots
<!-- Modal.stx -->
<template
<divclass=""
<headerclass=""
<slotname=""
Default Header
</slot
</header
<mainclass=""
<slot
</main
<footerclass=""
<slotname=""
<buttonclick=""Close</button
</slot
</footer
</div
</template
<!-- Usage -->
<Modalclose=""
<templateheader
<h2Custom Header</h2
</template
<pModal content here</p
<templatefooter
<Buttonclick=""Save</Button
<Buttonvariant=""click=""Cancel</Button
</template
</Modal
Scoped Slots
<!-- DataList.stx -->
<script
interface Props {
items: any[]
}
const props = defineProps<Props()
</script
<template
<ulclass=""
<liv-for=""key=""
<slotitem=""index=""
{{ item }}
</slot
</li
</ul
</template
<!-- Usage -->
<DataListitems=""
<templatedefault=""
<span{{ index + 1 }}. {{ user.name }} ({{ user.email }})</span
</template
</DataList
Composition
Using Composables
<script
import { useAuth } from '@/composables/useAuth'
import { useFetch } from '@/composables/useFetch'
const { user, isAuthenticated, logout } = useAuth()
const { data: posts, loading, error, refresh } = useFetch('/api/posts')
</script
<template
<divv-if=""
<pWelcome, {{ user.name }}</p
<buttonclick=""Logout</button
<divv-if=""Loading posts</div
<divv-else-if=""Error: {{ error.message }}</div
<ulv-else
<liv-for=""key=""
{{ post.title }}
</li
</ul
</div
</template
Creating Composables
// composables/useCounter.ts
import { computed, ref } from '@stacksjs/stx'
export function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initial
}
return {
count,
doubled,
increment,
decrement,
reset,
}
}
Component Patterns
Container/Presenter Pattern
<!-- UserListContainer.stx (Smart Component) -->
<script
import { onMounted, ref } from '@stacksjs/stx'
import UserList from './UserList.stx'
const users = ref([])
const loading = ref(true)
onMounted(async () => {
users.value = await fetchUsers()
loading.value = false
})
</script
<template
<UserListusers=""loading=""
</template
<!-- UserList.stx (Presentational Component) -->
<script
interface User {
id: number
name: string
}
defineProps<{
users: User[]
loading: boolean
}>()
</script
<template
<divv-if=""Loading</div
<ulv-else
<liv-for=""key=""
{{ user.name }}
</li
</ul
</template
Compound Components
<!-- Tabs.stx -->
<script
import { provide, ref } from '@stacksjs/stx'
const activeTab = ref(0)
provide('tabs', {
activeTab,
setActiveTab: (index: number) => {
activeTab.value = index
},
})
</script
<template
<divclass=""
<slot
</div
</template
<!-- TabList.stx -->
<template
<divclass=""role=""
<slot
</div
</template
<!-- Tab.stx -->
<script
import { inject } from '@stacksjs/stx'
const props = defineProps<{ index: number }>()
const { activeTab, setActiveTab } = inject('tabs')
</script
<template
<button
:class="['tab', { active: activeTab === props.index }]"
@click="setActiveTab(props.index)"
>
<slot
</button
</template
<!-- TabPanels.stx -->
<script
import { inject } from '@stacksjs/stx'
const { activeTab } = inject('tabs')
</script
<template
<divclass=""
<slotactiveTab=""
</div
</template
<!-- Usage -->
<Tabs
<TabList
<Tabindex=""Profile</Tab
<Tabindex=""Settings</Tab
<Tabindex=""Notifications</Tab
</TabList
<TabPanelsv-slot=""
<divv-show=""Profile content</div
<divv-show=""Settings content</div
<divv-show=""Notifications content</div
</TabPanels
</Tabs
Styling Components
Scoped Styles
<template
<divclass=""
<h2class=""{{ title }}</h2
</div
</template
<stylescoped
/* Only applies to this component */
.card {
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
</style
CSS Variables
<stylescoped
.btn {
--btn-bg: var(--color-primary, #3b82f6);
--btn-color: var(--color-white, #ffffff);
background: var(--btn-bg);
color: var(--btn-color);
}
.btn-secondary {
--btn-bg: var(--color-gray-500, #6b7280);
}
</style
Dynamic Classes
<template
<div
:class="[
'alert',
`alert-${type}`,
{ 'alert-dismissible': dismissible }
]"
>
<slot
</div
</template
Best Practices
DO
- Keep components focused - One responsibility per component
- Use TypeScript - Type props and emits for safety
- Document props - Use JSDoc comments for complex props
- Use slots wisely - Provide flexibility without complexity
- Scope styles - Prevent CSS leaks
DON'T
- Don't mutate props - Use events to communicate changes
- Don't over-abstract - Start simple, extract when needed
- Don't deeply nest - Flatten component hierarchies
- Don't use
any- Proper types catch bugs early
Related Documentation
- Views - Page templates
- Functions - Server-side logic
- Styling - CSS and styling guide
- State Management - Managing component state