Skip to content

Theme Disclosure

Expand appearance choices within the page layout.

Preview

This component uses Vue and Reka. Install the source below in your Vue or Nuxt app to try it with your theme provider.

Examples

Theme transition

A slide transition declared on this control. Duration is 500 milliseconds. Other controls keep their own settings.

This component uses Vue and Reka. Install the source below in your Vue or Nuxt app to try it with your theme provider.

Opens upward

Place the choices above the trigger and the label below it. Reserved space keeps the preview and trigger in place.

This component uses Vue and Reka. Install the source below in your Vue or Nuxt app to try it with your theme provider.

Icon with text

Show an icon beside the theme label.

This component uses Vue and Reka. Install the source below in your Vue or Nuxt app to try it with your theme provider.

When to use

  • Show appearance choices in a settings panel with space in the expansion direction.
  • Keep the choices open while people compare their selection.
  • Use a bounded scroll area for a longer list.

When not to use

  • Expansion would cover or move content in a compact toolbar. Use Theme Selector.
  • You need a general content disclosure. This control selects a theme preference.

Installation

Set up uitheme-web in an existing Tailwind app. Then install this control. The floating selector is a separate component.

Tailwind version
Source language
npx uitheme-cli add theme-disclosure --base reka --tailwind 4 --language ts

Usage

Place the control inside your theme provider. Expansion moves the content below it. The panel stays open after a selection. Its scroll area defaults to 240px and never exceeds 60% of the viewport height.

example.vue
<script setup lang="ts">
import { UIThemeProvider } from 'uitheme-web/vue';
import ThemeDisclosure from '@/components/ui/theme-disclosure.vue';
</script>

<template>
  <UIThemeProvider>
    <div class="flex h-96 flex-col justify-start">
      <ThemeDisclosure />
    </div>
  </UIThemeProvider>
</template>

Set direction="up" to place the choices above the trigger. Leave space above for the expanded panel. Anchor the control at the bottom of its container if the trigger must stay in place. The Examples section shows this layout. The default is direction="down".

Keep the label below the trigger when the panel opens upward. The component places the label there automatically. Keep the label above a downward-opening trigger. Preserve this order when you customize the layout.

Reserve enough height for the expanded panel, trigger, label, and frame padding. Align downward controls to the top and upward controls to the bottom. The examples reserve h-96 with the default maxHeight={240}. Adjust the space if you change the scroll limit. Opening a panel must not shift its trigger or the surrounding preview.

API reference

Use open with the change callback to control expansion. Use defaultOpen for an initial expanded state. Use maxHeight to set the scroll limit.

PropTypeBehavior
labelstringOverride the readable accessible label.
classstringExtend the root styles. Your classes replace default classes through the local merge helper.
classesobjectStyle root, trigger, content, item, and label parts.
disabledbooleanDisable interaction. Preference controls also wait for the provider to mount.
animationTypeThemeAnimationTypeOverride the UI theme transition for this control. Defaults to the provider setting.
durationnumberUI theme transition duration in milliseconds. Defaults to the provider setting.
slideDirectionSlideDirectionValid only with animationType set to ThemeAnimationType.SLIDE. Other animation types do not accept this prop.
display'text' | 'icon-text'Use text by default, or an icon with text. For an icon-only action, use Icon Theme Toggle. Light uses a sun, dark uses a moon, and system uses a monitor.
openbooleanControl whether the inline panel is expanded.
defaultOpenbooleanStart with the panel expanded. Defaults to false.
update:openeventReceive the next expanded state when the trigger is pressed.
direction'down' | 'up'Place choices below or above the trigger. Defaults to down. Use a bottom-anchored layout to keep an upward trigger in place.
maxHeightnumberLimit the scroll area in pixels. Defaults to 240 and never exceeds 60% of the viewport height.

Keyboard

KeyAction
Enter / SpaceExpand or collapse the focused trigger.
TabMove from the trigger into the expanded panel and its radio choices.
Arrow keysChange the selected radio within the group.
SpaceSelect the focused radio.

The panel stays open until you collapse it. Escape does not close an inline panel.

Accessibility

  • Keep the visible label, trigger name, and radio group name.
  • The trigger reports the expanded state. Hidden choices leave the tab order.
  • The disabled state prevents expansion and selection.
  • Keyboard controls use native buttons and radio inputs.

Theme behavior

The checked radio follows theme, including system. Choices come from themes. The color palette does not change.

The selected radio sets the transition origin. The library performs the page transition. Omitted transition props keep the provider settings.

AI Agent notes

  • Import the local control. Use uitheme-web/react or uitheme-web/vue for the runtime.
  • Read the configured provider choices. Do not invent theme values.
  • Preserve the label, focus styles, selected state, disabled state, and scroll limit.
  • Keep the label below an upward trigger and above a downward trigger.
  • Reserve room for the full bounded panel, trigger, label, and frame padding.
  • Anchor upward controls at the bottom and downward controls at the top. Opening the panel must not shift the preview.
  • Use the separate floating selector in compact layouts.
  • Keep page transitions in the library. Do not add a local transition helper.
  • Check keyboard selection, preference persistence, and reduced motion in the running app.

Source

The CLI reuses the shared cn utility. It repairs a missing utility and checks an existing utility before offering a replacement. This control installs one main component file. It does not install the floating selector.

<script setup lang="ts">
import { computed, shallowRef, useId } from 'vue';
import { useUITheme, type ThemeControlTransitionProps } from 'uitheme-web/vue';
import { ChevronDownIcon, SunIcon, MoonIcon, MonitorIcon } from '@lucide/vue';
import { cn } from '@/lib/utils';

const props = withDefaults(
  defineProps<
    ThemeControlTransitionProps & {
      label?: string;
      disabled?: boolean;
      open?: boolean;
      defaultOpen?: boolean;
      maxHeight?: number;
      direction?: 'up' | 'down';
      display?: 'text' | 'icon-text';

      class?: string;
      classes?: Partial<
        Record<'root' | 'trigger' | 'content' | 'item' | 'label', string>
      >;
    }
  >(),
  {
    label: 'Theme',
    disabled: false,
    open: undefined,
    defaultOpen: false,
    maxHeight: 240,
    direction: 'down',
    duration: undefined,
    slideDirection: undefined,
    display: 'text',
    class: undefined,
    classes: undefined,
  }
);

const emit = defineEmits<{ 'update:open': [open: boolean] }>();

const state = useUITheme(() => ({
  animationType: props.animationType,
  duration: props.duration,
  slideDirection: props.slideDirection,
}));

const internalOpen = shallowRef(props.defaultOpen);

const expanded = computed(() => props.open ?? internalOpen.value);

const id = useId();

const options = computed(() =>
  state.themes.map((value) => ({
    value,
    label: value
      .replaceAll('-', ' ')
      .replace(/\b\w/g, (letter) => letter.toUpperCase()),
  }))
);

const selected = computed(() =>
  options.value.find((option) => option.value === state.theme.value)
);

function toggle() {
  if (props.open === undefined) internalOpen.value = !expanded.value;
  emit(
    'update:open',
    props.open === undefined ? internalOpen.value : !expanded.value
  );
}

function select(value: string, event: Event) {
  const next = state.themes.find((option) => option === value);

  if (next)
    void state.switchTheme(next, {
      element:
        event.currentTarget instanceof HTMLElement
          ? event.currentTarget.closest('label')
          : null,
    });
}
</script>

<template>
  <div
    :data-direction="direction"
    :class="
      cn(
        'flex min-w-0 flex-col gap-2 data-[direction=up]:flex-col-reverse',
        props.classes?.root,
        props.class
      )
    "
  >
    <span
      :id="id"
      :class="cn('text-sm font-medium text-foreground', props.classes?.label)"
      >{{ label }}</span
    >
    <div
      :data-direction="direction"
      class="flex flex-col data-[direction=up]:flex-col-reverse min-w-0 [--disclosure-padding:var(--spacing,0.25rem)] rounded-[calc(var(--radius)+var(--disclosure-padding))] bg-muted p-[var(--disclosure-padding)] ring-1 ring-inset ring-border"
    >
      <button
        :id="`${id}-trigger`"
        type="button"
        :disabled="disabled || !state.mounted.value"
        :aria-expanded="expanded"
        :aria-controls="`${id}-content`"
        :class="
          cn(
            'flex min-h-10 w-full items-center justify-between gap-3 rounded-[var(--radius)] border border-border bg-card px-3 py-2 text-left text-sm font-medium text-card-foreground outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
            props.classes?.trigger
          )
        "
        @click="toggle"
      >
        <span class="flex min-w-0 items-center gap-2"
          ><span class="sr-only">{{ label }}: </span>
          <component
            :is="
              state.theme.value === 'light'
                ? SunIcon
                : state.theme.value === 'dark'
                  ? MoonIcon
                  : MonitorIcon
            "
            v-if="display === 'icon-text'"
            aria-hidden="true"
            class="size-4 shrink-0"
          />
          <slot name="value" :value="state.theme.value"
            ><span class="truncate">{{
              selected?.label ?? state.theme.value
            }}</span></slot
          >
        </span>
        <ChevronDownIcon
          aria-hidden="true"
          :class="
            cn(
              'size-4 shrink-0',
              (direction === 'up' ? !expanded : expanded) && 'rotate-180'
            )
          "
        />
      </button>
      <div
        :id="`${id}-content`"
        :data-direction="direction"
        :hidden="!expanded"
        role="region"
        :aria-labelledby="`${id}-trigger`"
        tabindex="0"
        :style="{ '--disclosure-max-height': `${maxHeight}px` }"
        :class="
          cn(
            'max-h-[min(var(--disclosure-max-height),60svh)] overflow-auto overscroll-contain rounded-b-[var(--radius)] data-[direction=up]:rounded-t-[var(--radius)] data-[direction=up]:rounded-b-none p-1 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [scrollbar-width:thin] [scrollbar-color:var(--muted-foreground)_transparent]',
            props.classes?.content
          )
        "
      >
        <fieldset
          :disabled="disabled || !state.mounted.value"
          :aria-labelledby="id"
          class="m-0 min-w-0 border-0 p-0"
        >
          <label
            v-for="option in options"
            :key="option.value"
            :class="
              cn(
                'flex cursor-pointer items-center gap-3 rounded-[max(0px,calc(var(--radius)-2px))] px-3 py-2 text-sm focus-within:ring-2 focus-within:ring-inset focus-within:ring-ring has-[:checked]:bg-accent has-[:checked]:text-accent-foreground has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50',
                props.classes?.item
              )
            "
          >
            <input
              type="radio"
              :name="id"
              :value="option.value"
              :checked="state.theme.value === option.value"
              class="size-3.5 shrink-0 accent-primary"
              @change="select(option.value, $event)"
            />
            <component
              :is="
                option.value === 'light'
                  ? SunIcon
                  : option.value === 'dark'
                    ? MoonIcon
                    : MonitorIcon
              "
              v-if="display === 'icon-text'"
              aria-hidden="true"
              class="size-4 shrink-0"
            />
            <slot name="option" :option="option">{{ option.label }}</slot>
          </label>
        </fieldset>
      </div>
    </div>
  </div>
</template>

Dependencies: @lucide/vue

Customization

Use the classes prop to style the root, label, trigger, content, and items. The frame uses theme tokens. Its outer radius equals the trigger radius plus the frame padding. This control uses solid borders.

Set animationType and duration for the page transition. Set slideDirection only with animationType="slide". Keep enough space in the expansion direction.