Components
Stacks provides a powerful component system for building reusable UI elements. Components in Stacks use a Vue-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 -->
<scriptsetuplang"ts"
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"container"
<Buttonvariant"primary"click"handleClick"
Click Me
</Button
<Buttonvariant"secondary"size"sm"
Small Button
</Button
<Buttonvariant"danger"disabled"isLoading"
Delete
</Button
</div
</template
<scriptsetuplang"ts"
import { ref } from 'vue'
const isLoading = ref(false)
function handleClick(event: MouseEvent) {
console.log('Button clicked!', event)
}
</script
Component Props
Defining Props
<scriptsetuplang"ts"
// 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
<scriptsetuplang"ts"
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"user-card"
<imgsrc"props.user.avatar ?? '/default-avatar.png'"
<h3{{ props.user.name }}</h3
<p{{ props.user.email }}</p
</div
</template
Prop Validation
<scriptsetuplang"ts"
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
<scriptsetuplang"ts"
// 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"handleSubmit"
<input
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<buttontype"submit"Submit</button
<buttontype"button"click"handleCancel"Cancel</button
</form
</template
v-model Support
<!-- Input.stx -->
<scriptsetuplang"ts"
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"searchQuery"placeholder"Search..."
</template
Slots
Default Slot
<!-- Card.stx -->
<template
<divclass"card"
<slot
</div
</template
<!-- Usage -->
<Card
<h2Card Title</h2
<pCard content goes here.</p
</Card
Named Slots
<!-- Modal.stx -->
<template
<divclass"modal"
<headerclass"modal-header"
<slotname"header"
Default Header
</slot
</header
<mainclass"modal-body"
<slot
</main
<footerclass"modal-footer"
<slotname"footer"
<buttonclick"$emit('close')"Close</button
</slot
</footer
</div
</template
<!-- Usage -->
<Modalclose"handleClose"
<templateheader
<h2Custom Header</h2
</template
<pModal content here.</p
<templatefooter
<Buttonclick"save"Save</Button
<Buttonvariant"secondary"click"cancel"Cancel</Button
</template
</Modal
Scoped Slots
<!-- DataList.stx -->
<scriptsetuplang"ts"
interface Props {
items: any[]
}
const props = defineProps<Props()
</script
<template
<ulclass"data-list"
<liv-for"(item, index) in props.items"key"index"
<slotitem"item"index"index"
{{ item }}
</slot
</li
</ul
</template
<!-- Usage -->
<DataListitems"users"
<templatedefault"{ item: user, index }"
<span{{ index + 1 }}. {{ user.name }} ({{ user.email }})</span
</template
</DataList
Composition
Using Composables
<scriptsetuplang"ts"
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"isAuthenticated"
<pWelcome, {{ user.name }}</p
<buttonclick"logout"Logout</button
<divv-if"loading"Loading posts...</div
<divv-else-if"error"Error: {{ error.message }}</div
<ulv-else
<liv-for"post in posts"key"post.id"
{{ post.title }}
</li
</ul
</div
</template
Creating Composables
// composables/useCounter.ts
import { computed, ref } from 'vue'
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) -->
<scriptsetuplang"ts"
import { onMounted, ref } from 'vue'
import UserList from './UserList.stx'
const users = ref([])
const loading = ref(true)
onMounted(async () => {
users.value = await fetchUsers()
loading.value = false
})
</script
<template
<UserListusers"users"loading"loading"
</template
<!-- UserList.stx (Presentational Component) -->
<scriptsetuplang"ts"
interface User {
id: number
name: string
}
defineProps<{
users: User[]
loading: boolean
}>()
</script
<template
<divv-if"loading"Loading...</div
<ulv-else
<liv-for"user in users"key"user.id"
{{ user.name }}
</li
</ul
</template
Compound Components
<!-- Tabs.stx -->
<scriptsetuplang"ts"
import { provide, ref } from 'vue'
const activeTab = ref(0)
provide('tabs', {
activeTab,
setActiveTab: (index: number) => {
activeTab.value = index
},
})
</script
<template
<divclass"tabs"
<slot
</div
</template
<!-- TabList.stx -->
<template
<divclass"tab-list"role"tablist"
<slot
</div
</template
<!-- Tab.stx -->
<scriptsetuplang"ts"
import { inject } from 'vue'
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 -->
<scriptsetuplang"ts"
import { inject } from 'vue'
const { activeTab } = inject('tabs')
</script
<template
<divclass"tab-panels"
<slotactiveTab"activeTab"
</div
</template
<!-- Usage -->
<Tabs
<TabList
<Tabindex"0"Profile</Tab
<Tabindex"1"Settings</Tab
<Tabindex"2"Notifications</Tab
</TabList
<TabPanelsv-slot"{ activeTab }"
<divv-show"activeTab === 0"Profile content</div
<divv-show"activeTab === 1"Settings content</div
<divv-show"activeTab === 2"Notifications content</div
</TabPanels
</Tabs
Styling Components
Scoped Styles
<template
<divclass"card"
<h2class"title"{{ 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