Theme Toggle
A button that steps Nuxt UI's colour mode through light → dark → system, swapping the
glyph to match the preference it just wrote: a sun, a moon, a monitor.
It owns no state. Everything comes from useColorMode(), the same global the rest of
your app reads, so a toggle in a header and one in a sidebar stay in step without any
props. That also means it emits nothing and takes no v-model — there is nothing to
bind, because there is nothing local.
The glyph is chosen from the preference, not from the resolved mode. On a machine set
to dark, clicking through to system shows the monitor, which is the honest answer:
that is what the user just chose, whatever it currently resolves to.
Usage
Drop it in and click it. Pass size to match a surrounding toolbar, and aria-label to
name the action — the component has no label of its own.
darkEvery click advances light → dark → system and writes Nuxt UI's global colour mode, so the whole page — not just this button — follows. The icon is chosen from the preference itself, which is why the monitor appears in system mode rather than the resolved one.
<template>
<div class="flex w-full max-w-md flex-col items-center gap-6 py-6">
<div class="flex items-center gap-3">
<MThemeToggle aria-label="Cycle colour mode" />
<span class="text-sm text-muted">
preference: <code class="font-mono">{{ colorMode.preference }}</code>
</span>
</div>
<div class="flex w-full items-center justify-between gap-3 rounded-xl border border-default px-4 py-3">
<span class="text-sm font-medium text-highlighted">
Northwind docs
</span>
<MThemeToggle size="sm" />
</div>
<p class="max-w-xs text-center text-xs text-muted">
Every click advances light → dark → system and writes Nuxt UI's global colour mode, so the whole
page — not just this button — follows. The icon is chosen from the preference itself, which is
why the monitor appears in system mode rather than the resolved one.
</p>
</div>
</template>
<script setup lang="ts">
import { useColorMode } from '#imports'
const colorMode = useColorMode()
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
size | ButtonProps['size'] | 'md' | Forwarded to the inner UButton. Bound explicitly, so a caller attribute of the same
name is consumed rather than merged. |
The component declares no slots and emits no events. Other attributes fall through to
the root UButton, which is what makes aria-label, class and the native type work
as usual. The button is hard-coded to the ghost variant, and variant is not exposed
as a prop.
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.themeToggle in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | |
iconWrapper | - | inline-flex items-center justify-center |
icon | - | w-5 h-5 |
Accessibility
The root is a real UButton, so it is a <button type="button"> with keyboard
activation and Nuxt UI's focus ring.
Nothing labels it out of the box. The single UIcon carries no accessible name, so an
unlabelled toggle is announced as just "button" — pass aria-label describing the
action. The label does not track the current mode, and it cannot: the component cycles
three preferences, so "Toggle dark mode" would be wrong one click in three. If you need
that, label it from your own useColorMode() state instead.
The mode change is visual only — no live region announces it, so a screen-reader user gets no confirmation that the click did anything.
Reduced motion is not consulted. The swap runs at a fixed 0.2s for every visitor,
including the exit half, which rotates the outgoing glyph a quarter turn.
Performance
colorMode.preferenceis the only reactive input, and it belongs to Nuxt UI rather than to this component. A mode change re-renders onemotion.spanand itsUIcon.- One icon is rendered per state. The
motion.spanis keyed oncolorMode.preference, so a mode change tears the old element down and mounts a new one, which is what re-runs:initial. Nothing is carried between preferences. - Everything animated is
opacity,scaleandrotate, so there is no layout work. The transition object is a fresh inline literal per render, which is cheap for a single element. useColorMode()writes to the document's class list and local storage; that cost belongs to Nuxt UI, not here.
<AnimatePresence mode="wait">, but motion-v does not
implement that mode, so the outgoing glyph is not guaranteed to be gone before the
incoming one appears — expect a brief crossfade with two icons visible rather than a
clean hand-off. Keep the pair visually close: sun to moon reads well, sun to a filled
square does not.