Skip to content

Icon Theme Toggle

Animate between sun and moon icons while changing the UI theme.

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.

Slide icons

Sun and moon exchange places with a short sideways movement.

Blur icons

A brief blur and crossfade joins the two states.

When to use

  • Use a compact light/dark action in a header or toolbar.
  • Use a short icon animation to make the changed state clear.

When not to use

  • People need to choose the system preference. Use Theme Switcher or Theme Selector.
  • The control changes a palette. Use Color Theme Selector.

Installation

Set up uitheme-web in an existing Tailwind app. Then install the component. The CLI copies Icon Theme Toggle and its Theme Toggle dependency. Both reuse the shared cn utility.

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

Usage

Use the control inside your theme provider. Import it from your app’s component directory.

example.tsx
'use client';

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

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

API reference

These props belong to the installed control. Its page transition settings override the provider for this action only.

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.
effect'rotate' | 'slide' | 'blur'Icon motion only. Defaults to rotate and is separate from the page transition.

Keyboard

KeyAction
Enter / SpaceToggle the resolved light/dark theme. Keyboard activation changes state without icon movement.

Accessibility

  • Keep the accessible label when changing the visible content. Icons are decorative.
  • The control stays disabled until the theme provider mounts.
  • Reduced motion removes travel and rotation. Icon state keeps a short opacity change. The selection surface changes position without travel.
  • Keyboard activation changes the theme without the pointer animation.

Theme behavior

The sun represents resolved light mode. The moon represents resolved dark mode. Activation replaces a system preference with the opposite resolved theme. The clicked button sets the page transition origin.

AI Agent notes

  • Use uitheme-web/react for React or uitheme-web/vue for Vue. Keep the theme provider around the control.
  • Keep page transitions in the control’s animationType and duration props. Do not add a standalone transition-settings control.
  • Keep input labels, focus styles, selected state, disabled state, and reduced-motion behavior when editing source.
  • Use the shared theme tokens. Do not hardcode a palette or radius.

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 control installs one main component file.

'use client';

import * as React from 'react';
import { SunIcon, MoonIcon } from 'lucide-react';
import { useUITheme } from 'uitheme-web/react';
import { ThemeToggle, type ThemeToggleProps } from './theme-toggle';
import { cn } from '@/lib/utils';
import type { ThemeControlTransitionProps } from 'uitheme-web/react';

export type IconThemeToggleProps = Omit<
  ThemeToggleProps,
  'display' | 'children' | 'animationType' | 'duration' | 'slideDirection'
> &
  ThemeControlTransitionProps & {
    effect?: 'rotate' | 'slide' | 'blur';
  };

export const IconThemeToggle = React.forwardRef<
  HTMLButtonElement,
  IconThemeToggleProps
>(function IconThemeToggle(
  { effect = 'rotate', label, classes, onKeyDown, onPointerDown, ...props },
  ref
) {
  const { resolvedTheme } = useUITheme();
  const [keyboard, setKeyboard] = React.useState(false);
  const dark = resolvedTheme === 'dark';

  const transition = keyboard
    ? 'transition-none motion-reduce:transition-none'
    : 'transition-[transform,translate,rotate,scale,opacity,filter] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-opacity motion-reduce:duration-100';

  const hidden = (side: 'sun' | 'moon') =>
    effect === 'slide'
      ? side === 'sun'
        ? '-translate-x-2'
        : 'translate-x-2'
      : effect === 'blur'
        ? 'scale-75 blur-[4px]'
        : side === 'sun'
          ? '-rotate-90 scale-75'
          : 'rotate-90 scale-75';

  const layer =
    'col-start-1 row-start-1 inline-flex size-4 items-center justify-center motion-reduce:!transform-none motion-reduce:!translate-x-0 motion-reduce:!rotate-0 motion-reduce:!scale-100 motion-reduce:!blur-none';

  return (
    <ThemeToggle
      {...props}
      ref={ref}
      label={label ?? `Switch to ${dark ? 'light' : 'dark'} theme`}
      onKeyDown={(event) => {
        setKeyboard(true);
        onKeyDown?.(event);
      }}
      onPointerDown={(event) => {
        setKeyboard(false);
        onPointerDown?.(event);
      }}
      classes={{
        ...classes,
        trigger: cn(
          'w-10 px-0 active:scale-[0.96] motion-reduce:active:scale-100',
          classes?.trigger
        ),
      }}
    >
      <span
        aria-hidden="true"
        data-effect={effect}
        className="grid size-4 overflow-hidden"
      >
        <span
          className={cn(
            layer,
            transition,
            dark ? cn('opacity-0', hidden('sun')) : 'opacity-100'
          )}
        >
          <SunIcon className="size-4" />
        </span>
        <span
          className={cn(
            layer,
            transition,
            dark ? 'opacity-100' : cn('opacity-0', hidden('moon'))
          )}
        >
          <MoonIcon className="size-4" />
        </span>
      </span>
    </ThemeToggle>
  );
});

Dependencies: 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 effect="rotate" (default), effect="slide", or effect="blur" for the icon exchange. Icon motion lasts 200ms and retargets from its current state on another pointer activation. Use animationType, duration, and slideDirection for the separate UI theme transition. Use native button props, label, and classes to customize the control.