Skip to content

Vue islands

Astro renders Vue components to HTML on the server and hydrates only the ones you mark with a client:* directive. Static Bloom’s Vue components work both ways: without a directive they ship as static HTML, and as islands they become interactive.

Comms

Radio volume70%

The panel above is a Vue island on this Starlight page: a switch, a slider and a select from @static-bloom/vue. Switch the flavor in the header and it follows.

  1. Add Vue, the Astro renderer, the components and Tailwind.

    Terminal window
    pnpm add @astrojs/vue vue @static-bloom/astro @static-bloom/vue @static-bloom/tailwind
    pnpm add -D tailwindcss @tailwindcss/vite
  2. Register the Vue renderer and the Tailwind Vite plugin next to the integration. Tailwind brings the theme, so the integration imports no CSS.

    astro.config.mjs
    import { defineConfig } from 'astro/config'
    import vue from '@astrojs/vue'
    import tailwindcss from '@tailwindcss/vite'
    import staticBloom from '@static-bloom/astro'
    export default defineConfig({
    integrations: [vue(), staticBloom({ styles: false })],
    vite: { plugins: [tailwindcss()] },
    })
  3. Create the global stylesheet and import it in your layout.

    src/styles/global.css
    @import 'tailwindcss';
    @import '@static-bloom/tailwind';
    @import '@static-bloom/vue/tailwind.css';

    The last line makes Tailwind scan the components’ sources, so their classes are generated.

On a Starlight site, keep the Starlight stylesheet instead and add only vue() to the integrations.

Each island is its own Vue app, so wrap its content in SbProvider. This is the panel above:

src/components/CommsPanel.vue
<script setup lang="ts">
// Island demo for the Vue islands page, which shows this file's source verbatim.
import { ref, useId } from 'vue'
import {
SbLabel,
SbProvider,
SbSelect,
SbSelectContent,
SbSelectItem,
SbSelectTrigger,
SbSelectValue,
SbSlider,
SbSwitch,
} from '@static-bloom/vue'
const id = useId()
const pushToTalk = ref(true)
const volume = ref([70])
const channel = ref('dispatch')
const channels = [
{ value: 'dispatch', label: 'Dispatch' },
{ value: 'crew', label: 'Crew' },
{ value: 'open', label: 'Open band' },
]
</script>
<template>
<SbProvider>
<div class="grid w-full max-w-sm gap-5 rounded-lg border border-line bg-panel p-5 shadow-sm">
<p class="m-0 font-display text-lg font-semibold text-fg">Comms</p>
<div class="flex items-center justify-between gap-4">
<SbLabel :for="`${id}-ptt`">Push to talk</SbLabel>
<SbSwitch :id="`${id}-ptt`" v-model="pushToTalk" />
</div>
<div class="grid gap-3">
<div class="flex items-center justify-between gap-4">
<SbLabel as="span">Radio volume</SbLabel>
<span class="font-mono text-xs text-fg-muted">{{ volume[0] }}%</span>
</div>
<!-- The thumb is the focusable control, so it carries the accessible name. -->
<SbSlider v-model="volume" :max="100" :step="5" :thumb-labels="['Radio volume']" />
</div>
<div class="grid gap-2">
<SbLabel :for="`${id}-channel`">Channel</SbLabel>
<SbSelect v-model="channel">
<SbSelectTrigger :id="`${id}-channel`">
<SbSelectValue placeholder="Pick a channel" />
</SbSelectTrigger>
<SbSelectContent>
<SbSelectItem v-for="item in channels" :key="item.value" :value="item.value">
{{ item.label }}
</SbSelectItem>
</SbSelectContent>
</SbSelect>
</div>
</div>
</SbProvider>
</template>

Then render it with a directive:

src/pages/settings.astro
---
import CommsPanel from '../components/CommsPanel.vue'
---
<CommsPanel client:visible />
Directive Hydrates Use for
none never: static HTML Components that only display
client:visible when it scrolls into view Most interactive components
client:idle when the browser is idle Below-the-fold widgets that should be ready soon
client:load immediately Controls the visitor uses first, above the fold

Islands do not share Vue state. Pass data through props, or keep shared state outside Vue, such as the flavor runtime.

The flavor runtime from the integration (or the Starlight plugin) owns <html>. Inside an island, useFlavor and SbFlavorSelect detect window.staticBloom, delegate to it, and listen to static-bloom:flavor-change, so every island and the page’s own selectors agree. The head script applies the flavor before islands hydrate, so nothing flashes.

To pin one island to a flavor, pass it to the provider: <SbProvider flavor="blackout"> wraps the content in a data-flavor scope.

Static Bloom’s components are safe to render on the server: SbProvider hands Vue’s useId to Reka so ids match after hydration, and useFlavor reads nothing from window until the component mounts. Avoid client:only="vue" unless a component cannot render on the server; it removes the HTML until the script loads.