Skip to content

Theme Toggle

Switch between light and dark with an animated transition.

Preview

Examples

Theme transition

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

Shadow borders

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

Icon with text

Show an icon beside the theme label.

When to use

  • Offer a compact light/dark action in a header, toolbar, or account menu.
  • Start an animated appearance change from the control that the person presses.

When not to use

  • People need to choose or return to the system preference. Use Theme Selector.
  • People are choosing a color palette. Use Color Theme Selector.

Installation

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

Tailwind version
Source language
npx uitheme-cli add theme-toggle --base radix --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/theme-toggle

Usage

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

example.tsx
'use client';

import { UIThemeProvider } from 'uitheme-web/react';
import { ThemeToggle } from '@/components/ui/theme-toggle';

export function Example() {
  return (
    <UIThemeProvider>
      <ThemeToggle />
    </UIThemeProvider>
  );
}

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.
classNamestringExtend the root styles. Your classes replace default classes through the local merge helper.
classesobjectStyle the root and trigger 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.

Keyboard

KeyAction
Enter / SpaceToggle between the resolved light and dark appearance.

Accessibility

  • Keep a meaningful label when changing the visible content.
  • The pressed state reports whether the resolved appearance is dark. The control stays disabled until the provider mounts.

Theme behavior

  • The toggle reads resolvedTheme and sets a light or dark preference. If the preference was system, the toggle replaces it.
  • The clicked button sets the origin for switchTheme. The engine changes appearance immediately when reduced motion is enabled or transitions are unsupported.

AI Agent notes

Use this checklist when a coding agent adds the control.

  • Use uitheme-web/react for the runtime. Import the control from its installed local file. The runtime does not export styled controls.
  • For Next.js, use NextUIThemeProvider from uitheme-web/next. For TanStack Start, follow the server bootstrap guide. Create server state for each request.
  • 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.
  • Read resolvedTheme to choose the opposite appearance. The system preference resolves to light or dark.
  • Keep the existing switchTheme call and its element origin when you change the button.
  • 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.

'use client';

import * as React from 'react';
import type { Theme } from 'uitheme-web/react';
import { useUITheme } from 'uitheme-web/react';
import { cn } from '@/lib/utils';
import { SunIcon, MoonIcon } from 'lucide-react';
import type { ThemeControlTransitionProps } from 'uitheme-web/react';
import { Toggle as TogglePrimitive } from 'radix-ui';

export type ThemeToggleClasses = {
  root?: string;
  trigger?: string;
};

export type ThemeToggleProps = Omit<
  React.ComponentPropsWithoutRef<'button'>,
  'className' | 'children' | 'onClick' | 'value'
> &
  ThemeControlTransitionProps & {
    value?: string;
    children?: React.ReactNode;
    onClick?: React.MouseEventHandler<HTMLButtonElement>;
    display?: 'text' | 'icon-text';
    borderStyle?: 'solid' | 'shadow';
    className?: string;
    classes?: ThemeToggleClasses;
    label?: string;
    disabled?: boolean;
  };

export const ThemeToggle = React.forwardRef<
  HTMLButtonElement,
  ThemeToggleProps
>(function ThemeToggle(
  {
    animationType,
    duration,
    slideDirection,
    borderStyle = 'solid',
    display = 'text',
    className,
    classes,
    label,
    disabled = false,
    children,
    onClick,
    ...buttonProps
  },
  forwardedRef
) {
  const { resolvedTheme, switchTheme, mounted } = useUITheme();
  const target: Theme = resolvedTheme === 'dark' ? 'light' : 'dark';
  const Icon = resolvedTheme === 'dark' ? MoonIcon : SunIcon;

  return (
    <div className={cn('inline-flex', classes?.root, className)}>
      <TogglePrimitive.Root
        {...buttonProps}
        ref={forwardedRef}
        type="button"
        data-border-style={borderStyle}
        aria-label={
          label ??
          (display === 'text'
            ? 'Toggle appearance'
            : `Switch to ${target} theme`)
        }
        pressed={resolvedTheme === 'dark'}
        disabled={disabled || !mounted}
        className={cn(
          'inline-flex h-10 gap-2 items-center justify-center rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
          '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]: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)] 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]',
          classes?.trigger
        )}
        onClick={(event) => {
          onClick?.(event);

          if (!event.defaultPrevented)
            void switchTheme(target, {
              element: event.currentTarget,
              animationType,
              duration,
              slideDirection,
              animationOff: event.detail === 0,
            });
        }}
      >
        {children ?? (
          <>
            {display !== 'text' && (
              <Icon
                aria-hidden="true"
                focusable="false"
                className="size-4 shrink-0"
              />
            )}
            {resolvedTheme === 'dark' ? 'Dark' : 'Light'}
          </>
        )}
      </TogglePrimitive.Root>
    </div>
  );
});

Dependencies: radix-ui, lucide-react

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.

Use display="icon-text" to show an icon beside the text. The default is display="text". For an icon-only action, use Icon Theme Toggle. Pass animationType, duration, and slideDirection to this control to set its UI theme 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.