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>
<h2>Card Title</h2>
<p>Card 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
<h2>Custom Header</h2>
</template>
<p>Modal 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="">
<p>Welcome, {{ 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 - Shared logic and composables under
resources/functions - Styling - CSS and styling guide
- State Management - Managing component state