| name | stacks-ui |
| description | Use when working with UI in a Stacks application — components, composables, reactivity (refs/watch/computed), Craft native components, Crosswind CSS, Crosswind utility framework, accessibility, or the STX templating engine. Covers @stacksjs/ui, @stacksjs/stx, and related UI tooling. |
| license | MIT |
| compatibility | Bun >= 1.3.0, TypeScript |
| allowed-tools | Read Edit Write Bash Grep Glob |
Stacks UI
Design & anti-slop skills
For premium, non-templated UI (layout, typography, color, motion) built on stx + Crosswind, reach for the design-taste skill family:
stacks-design-taste - flagship anti-slop frontend skill (brief inference, the three dials, layout/type/color discipline, strict pre-flight check)
- Aesthetic presets:
stacks-design-soft, stacks-design-minimalist, stacks-design-brutalist
stacks-redesign - audit-first upgrade of an existing UI; stacks-design-output - full-output enforcement (no placeholder or truncated components)
- Image-first:
stacks-image-to-code, plus reference-image generators stacks-imagegen-web, stacks-imagegen-mobile, stacks-brandkit
Key Paths
- Core package:
storage/framework/core/ui/src/
- Components:
storage/framework/core/ui/src/components/
- UI config:
config/ui.ts (Crosswind)
- STX config:
config/stx.ts
- STX engine:
node_modules/@stacksjs/stx/
- Crosswind:
node_modules/@cwcss/crosswind/
- Component types:
storage/framework/types/components.d.ts
Source Files
ui/src/
├── index.ts # Re-exports from @stacksjs/stx
├── components.ts # Component re-exports
└── components/
├── autocomplete.ts # Combobox, ComboboxInput, ComboboxOption, ComboboxOptions
├── disclosure.ts # Disclosure, DisclosureButton, DisclosurePanel
├── menu.ts # Menu, MenuButton, MenuItem, MenuItems
├── modal.ts # Dialog, DialogDescription, DialogPanel, DialogTitle
├── popover.ts # Popover, PopoverButton, PopoverPanel
├── radio-group.ts # RadioGroup, RadioGroupLabel, RadioGroupOption
├── select.ts # Combobox-based select
├── tabs.ts # Tab, TabGroup, TabList, TabPanel, TabPanels
├── toggle.ts # Switch
└── transition.ts # TransitionChild, TransitionRoot
Headless Components
import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from '@stacksjs/ui'
import { Dialog, DialogDescription, DialogPanel, DialogTitle } from '@stacksjs/ui'
import { Menu, MenuButton, MenuItem, MenuItems } from '@stacksjs/ui'
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@stacksjs/ui'
import { Switch } from '@stacksjs/ui'
import { TransitionChild, TransitionRoot } from '@stacksjs/ui'
Craft Native Components
Built-in components with native HTML fallbacks:
| Component | Fallback | Key Props |
|---|
craft-button | <button> | variant (primary/secondary/outline) |
craft-text-input | <input> | placeholder, value, type, disabled |
craft-textarea | <textarea> | placeholder, value, rows |
craft-checkbox | <input type="checkbox"> | checked, disabled, label |
craft-select | <select> | value, options, placeholder |
craft-modal | <dialog> | open, title, closable, size |
craft-tabs | <div> | activeTab, tabs |
craft-table | <table> | columns, rows, sortable, selectable |
craft-card | <div> | title, subtitle, variant |
craft-alert | <div> | variant, title, dismissible |
craft-toast | <div> | variant, duration, position |
craft-tooltip | <span> | content, position |
craft-pagination | <nav> | total, page, pageSize |
craft-code-editor | <textarea> | value, language, theme, lineNumbers |
craft-date-picker | <input type="date"> | value, min, max, format |
craft-color-picker | <input type="color"> | value, format |
|
Reactivity System
import { ref, namedRef, computed, watch } from '@stacksjs/stx'
const count = ref(0)
count.value = 5
const doubled = computed(() => count.value * 2)
const stop = watch(
() => count.value,
(newVal, oldVal) => console.log(`${oldVal} → ${newVal}`),
{ immediate: false }
)
stop()
Types
interface Ref<T> { value: T | null, readonly current: T | null }
interface ComponentInstance {
id: string, element: Element | null
mountHooks: LifecycleHook[], destroyHooks: CleanupFn[], updateHooks: LifecycleHook[]
refs: Map<string, Ref<any>>, watchers: Array<{ stop: () => void }>
isMounted: boolean
}
Lifecycle Hooks
import { onMount, onDestroy, onUpdate } from '@stacksjs/stx'
onMount(() => {
console.log('mounted')
return () => console.log('cleanup')
})
onDestroy(() => console.log('destroyed'))
onUpdate(() => console.log('updated'))
Dependency Injection
import { provide, inject, createInjectionKey, withInjectionScope } from '@stacksjs/stx'
const ThemeKey = createInjectionKey<string>('theme')
provide(ThemeKey, 'dark')
const theme = inject(ThemeKey)
const theme = inject(ThemeKey, 'light')
Browser Composables
Do not import them, and do not destructure the result. Both were wrong in this file
until 2026-08-03 and the destructure in particular is silently broken: it came from a
third implementation (browser-composables.ts) that is not even exported, so in a
<script client> block const { value, remove } = useLocalStorage(...) yields
undefined for both and nothing tells you.
These are auto-imported globals. useLocalStorage returns a signal — call it to
read, .set() to write, bare name in a template:
<script client>
const theme = useLocalStorage('theme', 'dark')
theme() // read -> 'dark'
theme.set('light') // write -> persists to localStorage
</script>
<div x-text="theme"></div> <!-- bare name; the template proxy unwraps it -->
Same in a functions/*.ts composable — still no import. The bundler marks stx external
and the auto-import transform rewrites the bare name into a window.stx destructure:
export function useTheme() {
const theme = useLocalStorage('theme', 'dark')
return { theme, toggle: () => theme.set(theme() === 'dark' ? 'light' : 'dark') }
}
Writing import { useLocalStorage } from 'stx' does work at runtime (same rewrite) but
TypeScript will complain, because the package index does not export it — that is
stacksjs/stx#1797, not a mistake on your side.
Never import { useLocalStorage } from '@stacksjs/stx/composables' for anything
template-facing. That is a different implementation returning a StorageRef
(.value / .get() / .remove()) which is not a signal, so nothing bound to it
ever re-renders. Signals from one implementation are invisible to the other. It is a
legitimate API for plain server/Node code and nothing else.
Two caveats worth knowing:
useLocalStorage JSON-stringifies on write. Any non-composable code reading the same
key (a pre-paint guard, say) must JSON.parse it — a stored empty string is the
two-character value "", which is truthy.
window.useSessionStorage is not assigned even though window.useLocalStorage is, so
the two diverge outside the <script client> destructure. Fixed upstream, not yet in
0.2.152.
For state shared across pages, use a store instead — it survives SPA navigation,
which a page-local signal does not:
export const useTheme = defineStore('theme', () => {
const mode = state('dark')
return { mode, toggle: () => mode.set(mode() === 'dark' ? 'light' : 'dark') }
}, { persist: true })
Note stores do not get the auto-import destructure that client scripts and
functions/*.ts composables get — store-loader.js never imports STX_RUNTIME_GLOBALS.
Only names assigned directly to window resolve there (state, effect, batch,
navigate, defineStore, useLocalStorage); useCookie and ~33 others live solely on
window.stx and are a bare ReferenceError inside a store. See
resources/stores/session.ts for the shape that works.
const { width, height } = useWindowSize()
const isDark = usePrefersDark()
const isOnline = useOnline()
const cleanup = useClickOutside(elementRef, handler)
Crosswind Configuration (config/ui.ts)
export default {
content: [
'./resources/**/*.{html,js,ts,jsx,tsx,stx}',
'./storage/framework/defaults/**/*.{html,js,ts,jsx,tsx,stx}',
'./storage/framework/views/**/*.{html,js,ts,jsx,tsx,stx}',
],
output: './storage/framework/assets/headwind.css',
minify: false,
} satisfies CrosswindOptions
STX Configuration (config/stx.ts)
export default {
componentsDir: 'resources/components',
layoutsDir: 'resources/layouts',
partialsDir: 'resources/partials',
} satisfies StxOptions
Full StxConfig
interface StxConfig {
enabled: boolean, debug: boolean
templatesDir?, componentsDir, partialsDir, layoutsDir?, defaultLayout?
ssr?: boolean, cache?: boolean, cachePath: string
i18n?: Partial<I18nConfig>
webComponents?: Partial<WebComponentConfig>
streaming?: Partial<StreamingConfig>
hydration?: Partial<HydrationConfig>
a11y?: Partial<A11yConfig>
seo?: Partial<SeoFeatureConfig>
animation?: Partial<AnimationConfig>
markdown?: Partial<MarkdownConfig>
pwa?: Partial<PwaConfig>
strict?: boolean | StrictModeConfig
}
Accessibility
import { checkA11y, autoFixA11y, scanA11yIssues } from '@stacksjs/stx'
const violations = await checkA11y(html, filePath)
const result = autoFixA11y(html, config)
const issues = await scanA11yIssues('./resources', { recursive: true })
interface A11yConfig {
enabled: boolean, addSrOnlyStyles: boolean
level: 'AA' | 'AAA', ignoreChecks?: string[], autoFix: boolean
}
Crosswind CSS Framework
Utility-first CSS (like Tailwind), built into Stacks:
import { buildCrosswindCSS, extractClassNames, generateCrosswindCSS } from '@stacksjs/stx'
const css = await buildCrosswindCSS(cwd)
const classNames = extractClassNames(htmlContent)
Features: theme config, 40+ variant modifiers, custom rules, shortcuts, attributify mode, bracket syntax, presets.
Gotchas
- @stacksjs/ui re-exports from @stacksjs/stx — the UI package is thin, the engine is in STX
- Craft components use native fallbacks —
preferNative: true renders plain HTML
- Refs are not Vue refs — similar API but custom reactive implementation
- Lifecycle hooks require component context — must be called within
setupComponent()
- Crosswind is not Tailwind — Stacks' own CSS utility implementation
- Crosswind is the utility engine — handles class extraction, CSS generation, purging
- STX is the templating engine — handles
.stx files, SSR, streaming, hydration
- Two CSS systems coexist — Crosswind (config) and Crosswind (engine)
- 150+ globally registered Vue components — no imports needed