Composables
vuecs ships several families of Vue composables:
@vuecs/core— primitives for building component wrappers (forwarders, focus / typeahead helpers, ID generation, state machines) plususeLocale(). Zero runtime deps beyond Vue 3.@vuecs/design— runtime color-mode state + theme-awareuseColorPalette()(dispatches through whichever themes the app installs).@vuecs/locale— browser-language-aware locale source with override + reset (useLocaleManager()/bindLocale()).
All three families run in any Vue 3 setup — VitePress, plain Vite, Astro, non-Nuxt SSR. The Nuxt module thin-wraps the design composables with cookie-backed storage for true SSR persistence.
@vuecs/core
These composables are exported from @vuecs/core's root entry (no subpath) alongside useComponentTheme and useComponentDefaults. Most are direct ports of Reka UI's shared utilities, kept in-tree so @vuecs/core stays zero-dep and so consumers don't need to install reka-ui to benefit from the patterns.
useForwardProps()
Returns a ComputedRef of the wrapper's props with undefined values dropped, suitable for v-bind-ing onto an inner component without leaking undefined over the inner's defaults.
import { useForwardProps } from '@vuecs/core';
const Wrapper = defineComponent({
props: {
size: { type: String, default: 'md' },
color: String,
},
setup(props) {
const forwarded = useForwardProps(props);
return () => h(Inner, forwarded.value);
},
});useEmitAsProps()
Converts the wrapper's declared emits into a map of onEventName handler props. Use with useForwardProps (or via useForwardPropsEmits) when forwarding to an inner component.
import { useEmitAsProps } from '@vuecs/core';
setup(_, { emit }) {
const emitsAsProps = useEmitAsProps(emit);
// → { onChange: fn, 'onUpdate:modelValue': fn, ... }
}useForwardPropsEmits()
useForwardProps + useEmitAsProps in one call. Returns a ComputedRef ready for v-bind.
import { useForwardPropsEmits } from '@vuecs/core';
setup(props, { emit }) {
const bound = useForwardPropsEmits(props, emit);
return () => h(Inner, bound.value);
}useForwardExpose()
Re-exposes the inner element/component on the wrapper's expose. Returns a forwardRef to attach as ref on the inner component, plus reactive currentRef / currentElement accessors.
import { useForwardExpose } from '@vuecs/core';
setup() {
const { forwardRef, currentElement } = useForwardExpose();
return () => h(InnerPrimitive, { ref: forwardRef });
}After this, the wrapper's template ref forwards through to whatever the inner primitive exposes (DOM element, exposed methods, etc.).
useArrowNavigation()
Resolves the next focusable item in a collection given an ArrowUp / ArrowDown / Home / End keyboard event. Items are matched by a CSS selector (default [data-vc-collection-item]).
Items are skipped when any of the following is true:
- the native
disabledattribute is set to anything other than"false" aria-disabled="true"- the
data-disabledattribute is present - the
disabledCSS class is present (VCLinktoggles its disabled state via class, not attribute) - the element fails
Element.checkVisibility()— collapsed submenu items insidedisplay: noneancestors are filtered out so arrow keys can't focus them. In environments without layout (jsdom, older browsers) the visibility check is treated as passing.
import { useArrowNavigation } from '@vuecs/core';
const onKeyDown = (event: KeyboardEvent) => {
useArrowNavigation(event, event.target as HTMLElement, listRef.value, {
arrowKeyOptions: 'vertical',
focus: true,
loop: true,
});
};Used internally by @vuecs/navigation for keyboard-driven nav between sibling items at any depth.
useTypeahead()
Accumulates keystrokes for ~1 s and resolves the next item whose text starts with the buffer. Repeated single characters cycle through items starting with that character.
import { useTypeahead } from '@vuecs/core';
const { handleTypeaheadSearch, resetTypeahead } = useTypeahead();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key.length === 1) {
handleTypeaheadSearch(event.key, items);
}
};getNextMatch and wrapArray are also exported for custom matching loops.
useId()
Thin wrapper around Vue 3.5's native useId() with a default vc- prefix. Pass a deterministic ID to short-circuit (useful for tests).
import { useId } from '@vuecs/core';
const id = useId(); // → 'vc-v-1'
const named = useId('my-id'); // → 'my-id'
const custom = useId(null, 'x'); // → 'x-v-2'useStateMachine()
Tiny state machine on top of ref(). Pass an initial state and a transition table; receive state and dispatch. Unknown events are ignored — they leave the state unchanged.
import { useStateMachine } from '@vuecs/core';
const { state, dispatch } = useStateMachine('closed', {
closed: { OPEN: 'open' },
open: { CLOSE: 'closed', TOGGLE: 'closed' },
} as const);
dispatch('OPEN'); // state.value === 'open'Used internally by @vuecs/overlays for open/closed transitions (modals, popovers, etc.); exported for consumers building their own stateful widgets.
usePrimitiveElement()
Resolves a template ref through #text / #comment $el nodes to the underlying DOM element — needed when the rendered element is delivered via <template> (e.g. <VCPrimitive :as-child>). Pair with <VCPrimitive> when building themable wrappers that need a reactive handle to the rendered element.
import { usePrimitiveElement, VCPrimitive } from '@vuecs/core';
setup() {
const { primitiveElement, currentElement } = usePrimitiveElement();
return () => h(VCPrimitive, { ref: primitiveElement, asChild: true });
}See Primitive (as / asChild) for the full reference. For wrappers that also expose the inner ref to parents, reach for useForwardExpose() above instead.
useLocale()
Reactive read-only accessor for the active BCP-47 locale, resolved through @vuecs/core's cross-cutting config (Config['locale'], default en-US). This is what locale-aware components (e.g. @vuecs/timeago) consume. It works with or without the @vuecs/locale plugin installed — the source of the value is config, which accepts any MaybeRef (a static string, a vue-i18n locale ref, or @vuecs/locale's navigator-backed source).
import { useLocale } from '@vuecs/core';
const locale = useLocale(); // ComputedRef<string> — falls back to 'en-US'The signature is useLocale(fallback?: string), but core registers en-US as a config-level default, so a custom fallback argument only takes effect when that registration is absent.
To write the locale globally, set the config key — app.use(vuecs, { config: { locale } }) or setConfig({ locale }) at runtime — or use @vuecs/locale for browser detection + override + reset.
@vuecs/locale
@vuecs/locale owns the locale source: browser-language detection (via @vueuse/core's useNavigatorLanguage), an explicit override, and a reset path. The install plugin bridges the resolved value into @vuecs/core's config, so useLocale() (above) picks it up. See the Locale page for the full setup.
useLocaleManager() (from @vuecs/locale)
The control surface for the locale source. Apply a backend-saved preference with set() and hand resolution back to the browser with reset().
import { useLocaleManager } from '@vuecs/locale';
const { source, resolved, isAuto, set, reset } = useLocaleManager();
set('de-DE'); // explicit override
reset(); // → 'auto' → browser language → fallback| Field | Type | Description |
|---|---|---|
source | Ref<string | 'auto'> | The writable source — a BCP-47 tag or the 'auto' sentinel |
resolved | ComputedRef<string> | Resolved concrete tag: override → navigator → fallback |
isAuto | ComputedRef<boolean> | Whether the source currently defers to the browser |
set(locale) | (string) => void | Apply an explicit override |
reset() | () => void | Reset to the configured initial (usually 'auto') |
bindLocale(source, options) is the lower-level building block (parallels bindColorMode) — pass any Ref<LocaleSource> (e.g. a cookie-backed ref for SSR) and it returns the same surface without the plugin's storage or config bridge.
@vuecs/design
Framework-agnostic Vue composables for the two pieces of state consumers usually want to mutate at runtime: the active palette and the dark/light color mode. Both live in @vuecs/design and are theme-aware — useColorPalette() walks installed themes' palette.handle hooks and concatenates the rendered CSS into the runtime <style id="vc-color-palette"> block. The same composable serves every theme (Tailwind, Bulma, community palette-aware themes); a palette-only theme just declares its palette.handle and the composable picks it up.
Requirements
- Vue 3 as a peer dep (already required by every component package).
@vueuse/coreas a peer dep of@vuecs/design.
npm install @vuecs/design @vueuse/core # color mode + theme-aware palette
npm install @vuecs/theme-tailwind # adds Tailwind class strings + palette.handle rendereruseColorPalette() (from @vuecs/design)
Reactive palette state with localStorage persistence. Wrapped via createSharedComposable — every call site shares the same ref + watcher, so picking a palette in one component updates every other consumer instantly.
<script setup lang="ts">
import { useColorPalette } from '@vuecs/design';
const { current, set, extend } = useColorPalette({
initial: { primary: 'blue', neutral: 'neutral' },
});
</script>
<template>
<p>Primary: {{ current.primary }}</p>
<button @click="extend({ primary: 'green' })">Switch to green</button>
<button @click="set({})">Reset to defaults</button>
</template>CSP nonce
The composable does not auto-wire a CSP nonce onto the inline <style> block. CSP-strict consumers pass it explicitly:
import { useConfig } from '@vuecs/core';
const { current, set } = useColorPalette({
nonce: () => useConfig('nonce').value,
});Previously (≤ vuecs 2.x) the per-theme useColorPalette wrappers in @vuecs/theme-tailwind / @vuecs/theme-bulma auto-wired this — those wrappers are deprecated since plan 026 and re-export from @vuecs/design without the nonce hook. Code that depended on the silent auto-wiring needs to opt in explicitly.
API
interface UseColorPaletteOptions<T> {
/** Initial palette when no persisted value exists. Default: {} */
initial?: T;
/** Persist via localStorage. Default: true */
persist?: boolean;
/** Storage key for the default backend. Default: 'vc-color-palette' */
storageKey?: string;
/** Sanitize stored values — defaults to filtering the canonical catalog. */
sanitize?: (raw: unknown) => T;
/** CSP nonce for the runtime <style> block. String or getter. */
nonce?: string | (() => string | undefined);
}
interface UseColorPaletteReturn<T> {
/** Read-only view of the current palette assignment. */
current: ComputedRef<T>;
/** Replace the entire palette. Pass `{}` to reset. */
set(palette: T): void;
/** Shallow-merge — preserves scales not in `partial`. */
extend(partial: Partial<T>): void;
}The generic T defaults to Record<string, string> so calls without explicit typing accept any palette shape. Pass ColorPaletteConfig (= Partial<Record<SemanticScaleName, ColorPaletteName>>) explicitly to get autocomplete + type-checking against the canonical six scales × 22 catalog palette names:
import { useColorPalette } from '@vuecs/design';
import type { ColorPaletteConfig } from '@vuecs/design';
const { current, set, extend } = useColorPalette<ColorPaletteConfig>();
// current.value.primary autocompletes the 22 catalog namesThemes that widen the palette-name union via ExtraColorPaletteNames (declaration merging) widen ColorPaletteConfig automatically.
set vs extend
| Verb | Semantic | Use when |
|---|---|---|
set({ primary: 'green' }) | Replace — drops every other scale | Resetting; full replacement; cookie/SSR scenarios where the source-of-truth is the new payload |
extend({ primary: 'green' }) | Shallow merge — keeps neutral, success, etc. | Per-scale UI controls (one slider per scale); typical interactive switching |
extend() mirrors the extend() marker in @vuecs/core's theme system: same vocabulary, same "merge instead of replace" intent.
Persistence
Default backend is localStorage at the vc-color-palette key. The default sanitizer filters input to the canonical catalog (six semantic scales × 22 catalog palette names) — unknown scales and non-catalog palette names are dropped silently. To opt out:
const { current, set } = useColorPalette({ persist: false });For SSR-readable persistence (e.g. Nuxt cookie), see Custom storage backends below.
Themes with diverging scale names
A theme whose internal naming differs from the canonical six (e.g. brand instead of primary) declares a scaleAliases map on its Theme.palette slot:
import type { Theme } from '@vuecs/core';
export default function acmeTheme(): Theme {
return {
elements: { /* ... */ },
palette: {
handle: (p) => `:root { --acme-brand: ${p.brand}; }`,
scaleAliases: { primary: 'brand', error: 'danger' },
},
};
}The dispatcher translates canonical input keys (primary, error) to the theme's local names (brand, danger) before calling handle. The public-facing palette config stays canonical — useColorPalette().set({ primary: 'green' }) works regardless of which themes are installed.
useColorMode() (from @vuecs/design)
Reactive light/dark/system mode with <html> class sync. Uses VueUse's usePreferredDark to resolve 'system'.
<script setup lang="ts">
import { useColorMode } from '@vuecs/design';
const { mode, resolved, isDark, toggle } = useColorMode();
</script>
<template>
<button @click="toggle">
{{ isDark ? '🌞 Light' : '🌙 Dark' }}
</button>
<p>Selected: {{ mode }} (resolves to {{ resolved }})</p>
</template>API
type ColorMode = 'light' | 'dark' | 'system';
interface UseColorModeOptions {
/** Initial mode when no persisted value exists. Default: 'system' */
initial?: ColorMode;
/** Persist via localStorage. Default: true */
persist?: boolean;
/** Storage key. Default: 'vc-color-mode' */
storageKey?: string;
/** Toggle .dark / .light class on <html>. Default: true */
syncClass?: boolean;
}
interface UseColorModeReturn {
/** Selected mode — may be 'system'. */
mode: WritableComputedRef<ColorMode>;
/** Effective mode — never 'system'. */
resolved: ComputedRef<'light' | 'dark'>;
/** Convenience boolean. Writable: true → 'dark', false → 'light'. */
isDark: WritableComputedRef<boolean>;
/** Flip light ↔ dark. */
toggle(): void;
}Custom storage backends
For SSR-readable persistence (cookies, request-aware storage), call the lower-level building blocks with any reactive Ref. bindColorPalette lives in @vuecs/design (generic — accepts a renderer); bindColorMode also lives there.
import { bindColorPalette, bindColorMode } from '@vuecs/design';
import { renderColorPaletteStyles } from '@vuecs/theme-tailwind';Both accept a Ref<T> and return the same shape as the high-level composables:
// Generic — any palette catalog plugs its own renderer + merge semantics
function bindColorPalette<T>(
source: Ref<T>,
options: {
render: (value: T) => string;
extend: (current: T, partial: Partial<T>) => T;
document?: Document;
},
): UseColorPaletteReturn<T>;
function bindColorMode(
source: Ref<ColorMode>,
options?: { syncClass?: boolean },
): UseColorModeReturn;The Nuxt module pattern (@vuecs/nuxt's useColorPalette):
// packages/nuxt/src/runtime/composables/useColorPalette.ts
import { useColorPaletteUnshared } from '@vuecs/design';
import { useCookie } from '#imports';
export function useColorPalette() {
const cookie = useCookie<Record<string, string>>('vc-color-palette', {
default: () => ({}),
watch: true,
});
return useColorPaletteUnshared({ source: cookie });
}Same return shape, SSR-aware persistence, theme-agnostic dispatch. useColorPaletteUnshared walks every installed theme's palette.handle hook on every render — no theme-specific imports needed in the Nuxt runtime. The generic bindColorPalette<T> is still the primitive of choice when you want to opt out of theme-runtime dispatch and supply your own renderer (e.g. for a custom palette flow that doesn't go through the ThemeManager).
SSR notes
- The composables are client-side stateful — on the server, watchers are still installed but
setColorPalette()/applyColorPaletteCss()is a DOM-only no-op, anduseStoragefrom VueUse reads/writes nothing. The first reactive read on the client triggers the initial paint. - For zero-FOUC palette / color mode on first paint, you need an SSR plugin that injects
<style id="vc-color-palette">andclass="dark"|"light"before client JS runs.@vuecs/nuxtships both SSR plugins (color-mode + palette) in one theme-agnostic module. Other SSR frameworks roll their own usingrenderColorPaletteFromThemes()from@vuecs/design(or a specific theme'srenderColorPaletteStyles()composed withapplyColorPaletteCss()). - VitePress / SSG hosts: there is currently a one-frame flash on first load when localStorage holds a non-default palette, since SSG'd HTML is fixed at build time. An inline pre-paint
<script>helper is the planned fix; until then, accept the one-frame flash.
See also
- Design Tokens — the CSS layer the composables drive
- Dark Mode —
.darkflip mechanics - Nuxt
useColorPalette— SSR-cookie variant - Nuxt
useColorMode— SSR-cookie variant