Skip to content

Color Theme Disclosure

Expand color palette choices within the page layout.

Preview

Color theme

Examples

Theme transition

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

Color theme

Opens upward

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

Color theme

When to use

  • Show color palette 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 Color 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 color-theme-disclosure --base base-ui --tailwind 4 --language ts

To install with shadcn, first add the UI-Theme registry to components.json. The style in components.json selects Radix or Base UI.

npx shadcn@latest add @uitheme/color-theme-disclosure

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.tsx
'use client';

import { UIThemeProvider } from 'uitheme-web/react';
import { ColorThemeDisclosure } from '@/components/ui/color-theme-disclosure';

export function Example() {
  return (
    <UIThemeProvider colorThemes={['default', 'caffeine']}>
      <div className="flex h-96 flex-col justify-start">
        <ColorThemeDisclosure />
      </div>
    </UIThemeProvider>
  );
}

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.
classNamestringExtend 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.
openbooleanControl whether the inline panel is expanded.
defaultOpenbooleanStart with the panel expanded. Defaults to false.
onOpenChange(open: boolean) => voidReceive 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 colorTheme. Choices come from colorThemes. Appearance 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.

'use client';

import * as React from 'react';
import {
  useUITheme,
  type ThemeControlTransitionProps,
} from 'uitheme-web/react';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';

export type ColorThemeDisclosureProps = ThemeControlTransitionProps & {
  label?: string;
  disabled?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  maxHeight?: number;
  direction?: 'up' | 'down';

  options?: readonly { value: string; label: string }[];
  className?: string;
  classes?: Partial<
    Record<'root' | 'trigger' | 'content' | 'item' | 'label', string>
  >;
};

export const ColorThemeDisclosure = React.forwardRef<
  HTMLButtonElement,
  ColorThemeDisclosureProps
>(function ColorThemeDisclosure(
  {
    label = 'Color theme',
    disabled = false,
    open,
    defaultOpen = false,
    onOpenChange,
    maxHeight = 240,
    direction = 'down',
    options: optionOverrides,
    animationType,
    duration,
    slideDirection,
    className,
    classes,
  },
  ref
) {
  const { colorTheme, colorThemes, mounted, switchColorTheme } = useUITheme();
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
  const expanded = open ?? internalOpen;
  const id = React.useId();

  const options =
    optionOverrides?.filter((option) => colorThemes.includes(option.value)) ??
    colorThemes.map((value) => ({
      value,
      label: value
        .replaceAll('-', ' ')
        .replace(/\b\w/g, (letter) => letter.toUpperCase()),
    }));

  const selected = options.find((option) => option.value === colorTheme);

  const style: React.CSSProperties & { '--disclosure-max-height': string } = {
    '--disclosure-max-height': `${maxHeight}px`,
  };

  return (
    <div
      data-direction={direction}
      className={cn(
        'flex min-w-0 flex-col gap-2 data-[direction=up]:flex-col-reverse',
        classes?.root,
        className
      )}
    >
      <span
        id={id}
        className={cn('text-sm font-medium text-foreground', classes?.label)}
      >
        {label}
      </span>
      <div
        data-direction={direction}
        className="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
          ref={ref}
          id={`${id}-trigger`}
          type="button"
          disabled={disabled || !mounted}
          aria-expanded={expanded}
          aria-controls={`${id}-content`}
          onClick={() => {
            if (open === undefined) setInternalOpen(!expanded);
            onOpenChange?.(!expanded);
          }}
          className={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',
            classes?.trigger
          )}
        >
          <span className="flex min-w-0 items-center gap-2">
            <span className="sr-only">{label}: </span>

            <span className="truncate">{selected?.label ?? colorTheme}</span>
          </span>
          <ChevronDownIcon
            aria-hidden="true"
            className={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={style}
          className={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]',
            classes?.content
          )}
        >
          <fieldset
            disabled={disabled || !mounted}
            aria-labelledby={id}
            className="m-0 min-w-0 border-0 p-0"
          >
            {options.map((option) => (
              <label
                key={option.value}
                className={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',
                  classes?.item
                )}
              >
                <input
                  type="radio"
                  name={id}
                  value={option.value}
                  checked={colorTheme === option.value}
                  onChange={(event) => {
                    const next = colorThemes.find(
                      (value) => value === option.value
                    );

                    if (next)
                      void switchColorTheme(next, {
                        element: event.currentTarget.closest('label'),
                        animationType,
                        duration,
                        slideDirection,
                      });
                  }}
                  className="size-3.5 shrink-0 accent-primary"
                />

                {option.label}
              </label>
            ))}
          </fieldset>
        </div>
      </div>
    </div>
  );
});

Dependencies: lucide-react

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.