Skip to content

Composables & utilities

Besides components, @static-bloom/vue exports two composables, two class helpers and a plugin. Everything on this page is a named export of the package root.

import { StaticBloomPlugin, cn, tv, useFlavor, useToast } from '@static-bloom/vue'

Reads and sets the flavor. Flavors & theming explains flavors themselves; this section is the reference.

flavor: auto · resolvedFlavor: midnight · isDark: true
UseFlavorDemo.vue
<script setup lang="ts">
import { SbButton, SbProvider, useFlavor } from '@static-bloom/vue'
const { flavor, resolvedFlavor, isDark, flavors, setFlavor } = useFlavor()
</script>
<template>
<SbProvider>
<div class="grid gap-3">
<div class="flex flex-wrap gap-2" role="group" aria-label="Flavor">
<SbButton
size="sm"
:variant="flavor === 'auto' ? 'solid' : 'outline'"
:aria-pressed="flavor === 'auto'"
@click="setFlavor('auto')"
>
Auto
</SbButton>
<SbButton
v-for="item in flavors"
:key="item.id"
size="sm"
:variant="flavor === item.id ? 'solid' : 'outline'"
:aria-pressed="flavor === item.id"
@click="setFlavor(item.id)"
>
{{ item.label }}
</SbButton>
</div>
<output class="text-sm text-fg-muted">
flavor: {{ flavor }} · resolvedFlavor: {{ resolvedFlavor }} · isDark: {{ isDark }}
</output>
</div>
</SbProvider>
</template>

On this site the buttons drive the page runtime, so the header’s selector and every other island follow.

Name Type Description
flavor Readonly<Ref<FlavorPreference>> The preference: 'auto', 'dusk', 'midnight', 'blackout' or 'daybreak'
resolvedFlavor ComputedRef<FlavorName> The flavor actually applied; 'auto' resolved through the system preference
isDark ComputedRef<boolean> Whether resolvedFlavor is a dark flavor (every flavor except Daybreak)
flavors readonly FlavorMeta[] { id, label, dark } for the four flavors
setFlavor (preference: FlavorPreference) => void Changes the preference

Options are read by the first useFlavor call in a Vue app, which creates the app’s shared state. Later calls in the same app return that state and ignore their options.

Option Type Default Effect
defaults { dark?: DarkFlavorName; light?: LightFlavorName } { dark: 'midnight', light: 'daybreak' } Flavors that 'auto' resolves to
defaultFlavor FlavorPreference 'auto' Preference used when nothing is stored
storageKey string 'static-bloom:flavor' localStorage key of the preference
storage { getItem; setItem } | null window.localStorage Where the preference persists; null disables persistence
target HTMLElement | (() => HTMLElement | null) | null document.documentElement Element that receives the attributes; null applies nothing
matchMedia (query: string) => MediaQueryLike | null window.matchMedia Source of prefers-color-scheme, replaceable in tests
runtime FlavorRuntimeLike | null window.staticBloom Page runtime to delegate to; null forces standalone mode

With a page runtime. When window.staticBloom exists (installed by the Astro integration or the Starlight plugin), the runtime owns <html>, storage and the media query. useFlavor reads its current preference on mount, setFlavor calls staticBloom.setFlavor(), and the change comes back through the runtime’s event. Storage, target and media options are not used in this mode.

Standalone. In-game pages and Vite apps have no runtime. Then useFlavor loads the stored preference, follows prefers-color-scheme while the preference is 'auto', writes each change to storage, and sets data-flavor, data-theme and color-scheme on the target.

Every instance listens on document for the static-bloom:flavor-change event, a CustomEvent whose detail is { flavor, resolved, theme }. The runtime dispatches it whenever the flavor changes, so all islands on a page, the header selector and SbFlavorSelect stay in agreement. Code outside Vue can listen to it too:

import { FLAVOR_CHANGE_EVENT } from '@static-bloom/vue'
document.addEventListener(FLAVOR_CHANGE_EVENT, (event) => {
if (event instanceof CustomEvent) console.log(event.detail.flavor, event.detail.resolved)
})

In standalone mode useFlavor does not dispatch the event itself; it only mirrors changes that another source announces.

  • useFlavor touches neither window nor document until the calling component mounts, so it renders on the server.
  • Every call inside one Vue app shares a single state, removed when the app unmounts. Each Astro island is its own app, with its own state, kept in step by the runtime event.
  • Called outside a component, it uses one detached state for the whole page and starts it at once.
Export Value or signature
FLAVOR_STORAGE_KEY 'static-bloom:flavor'
FLAVOR_CHANGE_EVENT 'static-bloom:flavor-change'
flavors Flavor metadata, { id, label, dark }[]
flavorNames ['dusk', 'midnight', 'blackout', 'daybreak']
flavorPreferences 'auto' followed by the flavor names
accentNames The 12 accent names
isFlavorName, isFlavorPreference, isAccentName Type guards for untrusted strings
isDarkFlavor(flavor) true for Dusk, Midnight and Blackout
themeOf(flavor) 'dark' or 'light', the value of data-theme

Queues toasts for the SbToaster mounted in the app. Toast components and the toaster’s props are documented in Overlays & menus.

  • The queue is empty.
UseToastDemo.vue
<script setup lang="ts">
import { SbButton, SbProvider, SbToaster, useToast } from '@static-bloom/vue'
const { toast, dismiss, toasts } = useToast()
let count = 0
function notify(): void {
count += 1
toast({
title: `Dispatch ${count}`,
description: 'A new job is on the board.',
intent: 'info',
duration: 0,
})
}
</script>
<template>
<SbProvider>
<SbToaster position="top-right" />
<div class="grid gap-3">
<div class="flex flex-wrap gap-2">
<SbButton @click="notify">Queue a toast</SbButton>
<SbButton variant="outline" :disabled="toasts.length === 0" @click="dismiss(toasts[0]?.id)"
>Dismiss oldest</SbButton
>
<SbButton variant="ghost" :disabled="toasts.length === 0" @click="dismiss()"
>Dismiss all</SbButton
>
</div>
<ul class="m-0 grid list-none gap-1 p-0 text-sm text-fg-muted">
<li v-for="item in toasts" :key="item.id">
{{ item.id }}: {{ item.title }} ({{ item.open ? 'open' : 'closing' }})
</li>
<li v-if="toasts.length === 0">The queue is empty.</li>
</ul>
</div>
</SbProvider>
</template>

useToast() takes no arguments and returns:

Name Type Description
toast (options: SbToastOptions) => string Queues a toast and returns its id
dismiss (id?: string) => void Closes the toast with that id, or every open toast without one
toasts ComputedRef<readonly SbToastItem[]> The live queue, including toasts playing their exit animation (open: false)
Option Type Default Effect
title string required First line of the toast
description string none Second line
intent 'primary' | 'secondary' | 'neutral' | 'info' | 'success' | 'warning' | 'danger' 'primary' Accent bar and icon color; warning and danger are announced assertively
duration number SbProvider’s toast-duration (5000) Visible time in ms; 0 keeps the toast until it is dismissed
action { label: string; onClick: () => void } none A button; the toast closes after onClick runs
icon Component intent icon Leading icon; info, success, warning and danger get one by default

An SbToastItem is the options plus a readonly id and open. The types are exported as SbToastOptions, SbToastItem, SbToastIntent, SbToastActionOptions and UseToastReturn.

  • The queue is shared by the whole page: any component can call toast(), and the toasts appear in the mounted SbToaster. Mount exactly one, inside SbProvider.
  • SbToaster’s position picks the corner: 'top-right', 'top-left', 'top-center', 'bottom-right' (default), 'bottom-left' or 'bottom-center'. The demo above uses 'top-right'.
  • A closed toast leaves the queue 600 ms after it closes, once its exit animation is over.
  • When the last SbToaster unmounts, the queue is cleared, so toasts never outlive their viewport.

cn(...classes) joins class values (strings, arrays, objects) and resolves Tailwind conflicts with the Static Bloom tailwind-merge config, so a later utility replaces an earlier one, including theme utilities such as glow-sm or text-fg-muted. It is the same function as @static-bloom/tailwind/merge’s cn, documented in Class merging. Every component merges its class prop with it.

cn('rounded-md px-3 text-fg-muted', isActive && 'text-primary', 'rounded-full')
// → 'px-3 text-primary rounded-full' when isActive is true

tv is tailwind-variants’ tv, created with createTV({ twMergeConfig }) from @static-bloom/tailwind/merge. Recipes you build with it merge classes exactly like the components’ own recipes, so variant classes and the class you pass at call time override the base without duplicates. Recipes with tailwind-variants covers the API.

Crew status: active
TvDemo.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { SbButton, SbProvider, cn, tv } from '@static-bloom/vue'
// A recipe for a project-level component, built with the package's configured `tv`.
const statusChip = tv({
base: 'inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm',
variants: {
status: {
idle: 'border-line text-fg-muted',
active: 'border-success text-success',
wanted: 'border-danger text-danger',
},
},
defaultVariants: { status: 'idle' },
})
const statuses = ['idle', 'active', 'wanted'] as const
const status = ref<(typeof statuses)[number]>('active')
// `cn` lets a caller's class win over the recipe: `rounded-full` replaces `rounded-md`.
const chipClass = computed(() => cn(statusChip({ status: status.value }), 'rounded-full'))
</script>
<template>
<SbProvider>
<div class="flex flex-wrap items-center gap-3">
<span :class="chipClass">Crew status: {{ status }}</span>
<SbButton
v-for="item in statuses"
:key="item"
size="sm"
:variant="status === item ? 'solid' : 'outline'"
:aria-pressed="status === item"
@click="status = item"
>
{{ item }}
</SbButton>
</div>
</SbProvider>
</template>

Use it to style your own components in the same idiom as the library. The package also re-exports the VariantProps and ClassValue types.

A Vue plugin that registers every Sb* component globally, from every family: foundations, actions, forms, selection, date and time, color, overlays and navigation. Composables and helpers are not registered; import them.

src/main.ts
import { createApp } from 'vue'
import { StaticBloomPlugin } from '@static-bloom/vue'
import App from './App.vue'
createApp(App).use(StaticBloomPlugin).mount('#app')

It takes no options. Registering everything pulls every component into the bundle, so prefer named imports outside prototypes, as the overview explains. You still need SbProvider at the root of the app.