Skip to content

Color Theme Selector

Choose a color palette without changing the appearance.

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.

Shadow borders

Use a prominent ambient shadow and inset edge in place of a solid border.

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

  • Let people choose an app palette without changing light or dark appearance.

  • Show readable labels for the palette keys configured on the provider.

  • Use the floating menu in headers, toolbars, and compact layouts.

When not to use

  • People are choosing light, dark, or system. Use Theme Selector.
  • You need a free-form color picker. This control selects registered palette keys.

Installation

Set up uitheme-web in an existing Tailwind app. Then install the component.

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

Usage

Use the component inside your theme provider. Use import paths that match your app’s component directory.

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

<template>
  <UIThemeProvider :color-themes="['default', 'caffeine']">
    <ColorThemeSelector />
  </UIThemeProvider>
</template>

For inline choices, install Color Theme Disclosure. The floating selector does not install disclosure code.

API reference

These props belong to the installed local control. Configure theme options on the provider.

PropTypeBehavior
labelstringOverride the readable accessible label.
borderStyle'solid' | 'shadow'Use the default solid border or prominent shadow borders. Both use the same space and theme tokens.
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.
options{ value, label }[]Change palette labels. Values must already exist in the provider’s colorThemes.

Keyboard

KeyAction
Enter / SpaceOpen the selector or confirm the focused palette.
Arrow keysMove between palette options.
EscapeClose the menu and return focus to the trigger.

Accessibility

  • Use readable option labels instead of relying on color swatches alone.
  • Use option values from colorThemes. Custom labels do not register new palettes.

Theme behavior

  • The control calls switchColorTheme. The appearance preference does not change.
  • Register each palette key on the provider. Define its light and dark CSS variables. Selecting a key does not create its colors.

AI Agent notes

Use this checklist when a coding agent adds the control.

  • Use uitheme-web/vue for the runtime. Import the control from its installed local file. The runtime does not export styled controls.
  • Use createUITheme or UIThemeProvider for Vue. In Nuxt 4, use the uitheme-web/nuxt module. Follow the provider setup in the installation guide.
  • Before installation, select the framework, primitive base, Tailwind major version, and language. Run the CLI inside the app or pass --cwd.
  • Preview changes with --dry-run. Keep edited local files. Review the differences before using --overwrite.
  • Keep appearance and colorTheme separate. Use the palette action to change colors.
  • Check that the selected palette changes the computed semantic colors in light and dark appearance. A changed label alone does not confirm this.
  • Check keyboard controls in the running app. Check that appearance and palette choices survive a reload. Check reduced motion.

Source

The CLI reuses your shared cn utility. If it is missing, installation creates it at the configured utils path. Existing utilities are checked before a replacement is offered.

These are the files the CLI installs. The component source stays in your app.

<script setup lang="ts">
import type { ThemeControlTransitionProps } from 'uitheme-web/vue';
import { CheckIcon, ChevronDownIcon } from '@lucide/vue';
import { computed, useTemplateRef, useId } from 'vue';
import {
  SelectContent,
  SelectItem,
  SelectItemIndicator,
  SelectItemText,
  SelectViewport,
  SelectPortal,
  SelectRoot,
  SelectTrigger,
  SelectValue,
} from 'reka-ui';
import type { AcceptableValue } from 'reka-ui';
import { useUITheme } from 'uitheme-web/vue';
import { cn } from '@/lib/utils';

const props = withDefaults(
  defineProps<
    ThemeControlTransitionProps & {
      borderStyle?: 'solid' | 'shadow';
      class?: string;
      classes?: Partial<
        Record<
          'root' | 'trigger' | 'content' | 'item' | 'label' | 'track' | 'thumb',
          string
        >
      >;
      disabled?: boolean;
      label?: string;
      options?: readonly {
        value: string;
        label: string;
      }[];
    }
  >(),
  {
    duration: undefined,
    slideDirection: undefined,
    label: 'Color theme',
    borderStyle: 'solid',
    class: undefined,
    classes: undefined,
    options: undefined,
  }
);

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

const trigger = useTemplateRef<InstanceType<typeof SelectTrigger>>('trigger');

const id = useId();

const options = computed(
  () =>
    props.options ??
    theme.colorThemes.map((value) => ({
      value,
      label: value[0]!.toUpperCase() + value.slice(1),
    }))
);

const rootClass = computed(() =>
  cn('inline-flex flex-col gap-1', props.class, props.classes?.['root'])
);

const triggerClass = computed(() =>
  cn(
    cn(
      'flex h-10 w-full min-w-0 items-center justify-between gap-2 rounded-[var(--radius)] border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-[color,box-shadow,border-color] hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none',
      'data-[border-style=shadow]:[--shadow-border-edge:theme(colors.border)] data-[border-style=shadow]:[--shadow-border-focus:theme(colors.ring)] data-[border-style=shadow]:border-transparent data-[border-style=shadow]:focus-visible:border-transparent data-[border-style=shadow]:ring-0 data-[border-style=shadow]:focus-visible:ring-0 data-[border-style=shadow]:shadow-[0_0_4px_rgb(0_0_0_/_0.25),inset_0_0_0_1px_var(--shadow-border-edge)] data-[border-style=shadow]:forced-colors:outline data-[border-style=shadow]:forced-colors:outline-1 data-[border-style=shadow]:forced-colors:outline-[CanvasText] data-[border-style=shadow]:forced-colors:focus-visible:outline-2 data-[border-style=shadow]:forced-colors:focus-visible:outline-[Highlight] data-[border-style=shadow]:hover:shadow-[0_0_4px_rgb(0_0_0_/_0.25),inset_0_0_0_1px_color-mix(in_oklch,var(--shadow-border-focus)_55%,transparent)] data-[border-style=shadow]:focus-visible:shadow-[0_0_12px_color-mix(in_oklch,var(--shadow-border-focus)_18%,transparent),0_1px_3px_rgb(0_0_0_/_0.1),inset_0_0_0_2px_color-mix(in_oklch,var(--shadow-border-focus)_55%,transparent)]'
    ),
    props.classes?.['trigger']
  )
);

const contentClass = computed(() =>
  cn(
    cn(
      'z-50 min-w-[var(--reka-select-trigger-width)] max-h-[min(20rem,var(--reka-select-content-available-height,20rem))] overflow-y-auto overscroll-contain rounded-[var(--radius)] border border-border bg-popover p-1 text-popover-foreground shadow-md [scrollbar-width:thin]',
      'data-[border-style=shadow]:[--shadow-border-edge:theme(colors.border)] data-[border-style=shadow]:[--shadow-border-focus:theme(colors.ring)] data-[border-style=shadow]:border-transparent data-[border-style=shadow]:focus-visible:border-transparent data-[border-style=shadow]:ring-0 data-[border-style=shadow]:focus-visible:ring-0 data-[border-style=shadow]:shadow-[0_0_4px_rgb(0_0_0_/_0.25),inset_0_0_0_1px_var(--shadow-border-edge)] data-[border-style=shadow]:forced-colors:outline data-[border-style=shadow]:forced-colors:outline-1 data-[border-style=shadow]:forced-colors:outline-[CanvasText] data-[border-style=shadow]:forced-colors:focus-visible:outline-2 data-[border-style=shadow]:forced-colors:focus-visible:outline-[Highlight]'
    ),
    props.classes?.['content']
  )
);

const itemClass = computed(() =>
  cn(
    'relative flex min-h-9 w-full cursor-default select-none items-center gap-2 rounded-[max(0px,calc(var(--radius)-4px))] py-2 pl-3 pr-9 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[selected]:bg-accent/60 data-[state=checked]:bg-accent/60 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
    props.classes?.['item']
  )
);

function select(value: AcceptableValue, origin?: HTMLElement | null) {
  const selected = theme.colorThemes.find((option) => option === value);
  const element = origin ?? trigger.value?.$el;

  if (selected)
    void theme.switchColorTheme(selected, {
      element: element instanceof HTMLElement ? element : null,
    });
}
</script>

<template>
  <div :class="rootClass">
    <label
      :for="id"
      :class="
        cn('text-sm font-medium text-foreground', props.classes?.['label'])
      "
      >{{ label }}</label
    >
    <SelectRoot
      :model-value="theme.colorTheme.value"
      :disabled="disabled || !theme.mounted.value"
      @update:model-value="select"
    >
      <SelectTrigger
        :id="id"
        ref="trigger"
        :data-border-style="borderStyle"
        :class="triggerClass"
        :aria-label="label"
        ><SelectValue class="min-w-0 truncate text-left" /><ChevronDownIcon
          aria-hidden="true"
          class="size-4 shrink-0 text-muted-foreground"
      /></SelectTrigger>
      <SelectPortal>
        <SelectContent
          :data-border-style="borderStyle"
          :class="contentClass"
          position="popper"
          :side-offset="6"
        >
          <SelectViewport
            ><SelectItem
              v-for="option in options"
              :key="option.value"
              :value="option.value"
              :class="itemClass"
              ><SelectItemText>{{ option.label }}</SelectItemText
              ><SelectItemIndicator
                class="absolute right-3 flex size-4 items-center justify-center"
                ><CheckIcon
                  aria-hidden="true"
                  class="size-4" /></SelectItemIndicator></SelectItem
          ></SelectViewport>
        </SelectContent>
      </SelectPortal>
    </SelectRoot>
  </div>
</template>

Dependencies: reka-ui, @lucide/vue

Customization

Use borderStyle="shadow" in React or border-style="shadow" in Vue for prominent shadow borders. The default is solid.

Use slideDirection only when animationType is ThemeAnimationType.SLIDE. Set both props on the control when you need a slide direction override.

Pass animationType, duration, and slideDirection to set this control's palette transition. These props override the provider settings for this action only.

Keyboard focus shows the theme’s focus ring.

Edit the installed source. Use className in React or class in Vue to style the root. Use the classes prop to style individual parts. Use label props to change the visible text.