Skip to content

Composables

vuecs ships several families of Vue composables:

  • @vuecs/core — primitives for building component wrappers (forwarders, focus / typeahead helpers, ID generation, state machines) plus useLocale(). Zero runtime deps beyond Vue 3.
  • @vuecs/design — runtime color-mode state + theme-aware useColorPalette() (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.

ts
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.

ts
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.

ts
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.

ts
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 disabled attribute is set to anything other than "false"
  • aria-disabled="true"
  • the data-disabled attribute is present
  • the disabled CSS class is present (VCLink toggles its disabled state via class, not attribute)
  • the element fails Element.checkVisibility() — collapsed submenu items inside display: none ancestors are filtered out so arrow keys can't focus them. In environments without layout (jsdom, older browsers) the visibility check is treated as passing.
ts
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.

ts
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).

ts
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.

ts
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.

ts
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).

ts
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().

ts
import { useLocaleManager } from '@vuecs/locale';

const { source, resolved, isAuto, set, reset } = useLocaleManager();

set('de-DE'); // explicit override
reset();      // → 'auto' → browser language → fallback
FieldTypeDescription
sourceRef<string | 'auto'>The writable source — a BCP-47 tag or the 'auto' sentinel
resolvedComputedRef<string>Resolved concrete tag: override → navigator → fallback
isAutoComputedRef<boolean>Whether the source currently defers to the browser
set(locale)(string) => voidApply an explicit override
reset()() => voidReset 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/core as a peer dep of @vuecs/design.
bash
npm install @vuecs/design @vueuse/core                  # color mode + theme-aware palette
npm install @vuecs/theme-tailwind                       # adds Tailwind class strings + palette.handle renderer

useColorPalette() (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.

vue
<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:

ts
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

ts
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:

ts
import { useColorPalette } from '@vuecs/design';
import type { ColorPaletteConfig } from '@vuecs/design';

const { current, set, extend } = useColorPalette<ColorPaletteConfig>();
// current.value.primary autocompletes the 22 catalog names

Themes that widen the palette-name union via ExtraColorPaletteNames (declaration merging) widen ColorPaletteConfig automatically.

set vs extend

VerbSemanticUse when
set({ primary: 'green' })Replace — drops every other scaleResetting; 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:

ts
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:

ts
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'.

vue
<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

ts
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.

ts
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:

ts
// 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):

ts
// 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, and useStorage from 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"> and class="dark"|"light" before client JS runs. @vuecs/nuxt ships both SSR plugins (color-mode + palette) in one theme-agnostic module. Other SSR frameworks roll their own using renderColorPaletteFromThemes() from @vuecs/design (or a specific theme's renderColorPaletteStyles() composed with applyColorPaletteCss()).
  • 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

Released under the Apache 2.0 License.