Overlay

SwitchTheme

Light/dark theme switch

Preview

Sample surface
size

Code

import css from "./SwitchTheme.module.css";
import { useState } from "react";

import { SunIcon } from "@phosphor-icons/react/dist/csr/Sun";
import { MoonIcon } from "@phosphor-icons/react/dist/csr/Moon";

type Size = "sm" | "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface SwitchThemeProps {
  size: Size | ResponsiveSize;
  "aria-label": string;
  checked: boolean;
  onChange: (checked: boolean) => void;
}

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function SwitchTheme({
  size,
  "aria-label": ariaLabel,
  checked,
  onChange,
}: SwitchThemeProps) {
  // Gates the dot animation so a switch that mounts already on doesn't play it.
  const [interacted, setInteracted] = useState(false);

  const toggle = () => {
    setInteracted(true);
    onChange(!checked);
  };

  const onAnimationEnd = () => setInteracted(false);

  return (
    <button
      type="button"
      className={`${css.switch} ${getSizeClasses(size)} ${checked ? css.active : ""}`}
      role="switch"
      aria-label={ariaLabel}
      aria-checked={checked}
      onClick={toggle}
    >
      <div className={css.switch_sun} aria-hidden="true">
        <SunIcon weight="bold" />
      </div>
      <div className={css.switch_moon} aria-hidden="true">
        <MoonIcon weight="bold" />
      </div>
      <div
        className={`${css.switch_dot} ${interacted ? css.animated : ""}`}
        aria-hidden="true"
        onAnimationEnd={onAnimationEnd}
      >
        {checked ? (
          <SunIcon weight="bold" />
        ) : (
          <MoonIcon weight="bold" />
        )}
      </div>
    </button>
  );
}

Props

PropTypeRequiredDescription
size"sm" | "md" | "lg" | ResponsiveSizerequiredControl size (track, dot and icons). Fixed or responsive.
aria-labelstringrequiredAccessible name of the button (icon-only: without it, a screen reader would announce nothing).
checkedbooleanrequiredOn state (controlled). On is the light theme, with the sun on the dot.
onChange(checked: boolean) => voidrequiredCallback with the new state on every change.