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>
);
}
.switch {
position: relative;
display: flex;
align-items: center;
width: var(--switch-width);
height: var(--switch-height);
padding: 0;
background: none;
color: inherit;
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-pill);
cursor: pointer;
user-select: none;
/* Off the control ladder: a 2:1 pill track, and 24 and 26 have no token. */
&.sm {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
&.sm_base {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_base {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_base {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
@media (min-width: 480px) {
&.sm_mobile {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_mobile {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_mobile {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
}
@media (min-width: 768px) {
&.sm_tablet {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_tablet {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_tablet {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_desktop {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_desktop {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
}
@media (min-width: 1440px) {
&.sm_wide {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_wide {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_wide {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
}
.switch_sun,
.switch_moon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: var(--switch-icon);
height: var(--switch-icon);
color: var(--color-text);
opacity: 0.5;
transition: opacity 0.3s ease;
}
.switch_sun {
left: 3px;
}
.switch_moon {
right: 3px;
opacity: 0;
}
.switch_dot {
position: absolute;
right: 3px;
display: flex;
align-items: center;
justify-content: center;
width: var(--switch-icon);
height: var(--switch-icon);
border-radius: var(--radius-full);
background: var(--color-text);
color: var(--color-bg);
&.animated {
transition: right 0.3s ease;
animation: stretch-off 0.3s ease;
svg {
animation: counter-stretch 0.3s ease;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
animation: none;
svg {
animation: none;
}
}
}
}
&.active {
.switch_sun {
opacity: 0;
}
.switch_moon {
opacity: 0.5;
}
.switch_dot {
right: calc(100% - var(--switch-icon) - 3px);
&.animated {
animation: stretch-on 0.3s ease;
svg {
animation: counter-stretch 0.3s ease;
}
@media (prefers-reduced-motion: reduce) {
animation: none;
svg {
animation: none;
}
}
}
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
@keyframes stretch-on {
50% {
transform: scaleX(1.5);
}
}
@keyframes stretch-off {
50% {
transform: scaleX(1.5);
}
}
@keyframes counter-stretch {
50% {
transform: scaleX(calc(1 / 1.5));
}
}
<script setup lang="ts">
import { computed, ref } from "vue";
import { PhSun, PhMoon } from "@phosphor-icons/vue";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SwitchThemeProps {
size: Size | ResponsiveSize;
ariaLabel: string;
}
const { size, ariaLabel } = defineProps<SwitchThemeProps>();
const model = defineModel<boolean>({ required: true });
// Gates the dot animation so a switch that mounts already on doesn't play it.
const interacted = ref(false);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const toggle = () => {
interacted.value = true;
model.value = !model.value;
};
const onAnimationEnd = () => (interacted.value = false);
</script>
<template>
<button
type="button"
class="switch"
:class="[sizeClasses, { active: model }]"
role="switch"
:aria-label="ariaLabel"
:aria-checked="model"
@click="toggle"
>
<div class="switch_sun" aria-hidden="true">
<PhSun weight="bold" />
</div>
<div class="switch_moon" aria-hidden="true">
<PhMoon weight="bold" />
</div>
<div
class="switch_dot"
:class="{ animated: interacted }"
aria-hidden="true"
@animationend="onAnimationEnd"
>
<PhSun v-if="model" weight="bold" />
<PhMoon v-else weight="bold" />
</div>
</button>
</template>
<style scoped>
.switch {
position: relative;
display: flex;
align-items: center;
width: var(--switch-width);
height: var(--switch-height);
padding: 0;
background: none;
color: inherit;
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-pill);
cursor: pointer;
user-select: none;
/* Off the control ladder: a 2:1 pill track, and 24 and 26 have no token. */
&.sm {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
&.sm_base {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_base {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_base {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
@media (min-width: 480px) {
&.sm_mobile {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_mobile {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_mobile {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
}
@media (min-width: 768px) {
&.sm_tablet {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_tablet {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_tablet {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_desktop {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_desktop {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
}
@media (min-width: 1440px) {
&.sm_wide {
--switch-width: 48px;
--switch-height: 24px;
--switch-icon: 16px;
font-size: 12px;
}
&.md_wide {
--switch-width: 52px;
--switch-height: 26px;
--switch-icon: 18px;
font-size: 13px;
}
&.lg_wide {
--switch-width: 56px;
--switch-height: 28px;
--switch-icon: 20px;
font-size: 14px;
}
}
.switch_sun,
.switch_moon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: var(--switch-icon);
height: var(--switch-icon);
color: var(--color-text);
opacity: 0.5;
transition: opacity 0.3s ease;
}
.switch_sun {
left: 3px;
}
.switch_moon {
right: 3px;
opacity: 0;
}
.switch_dot {
position: absolute;
right: 3px;
display: flex;
align-items: center;
justify-content: center;
width: var(--switch-icon);
height: var(--switch-icon);
border-radius: var(--radius-full);
background: var(--color-text);
color: var(--color-bg);
&.animated {
transition: right 0.3s ease;
animation: stretch-off 0.3s ease;
svg {
animation: counter-stretch 0.3s ease;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
animation: none;
svg {
animation: none;
}
}
}
}
&.active {
.switch_sun {
opacity: 0;
}
.switch_moon {
opacity: 0.5;
}
.switch_dot {
right: calc(100% - var(--switch-icon) - 3px);
&.animated {
animation: stretch-on 0.3s ease;
svg {
animation: counter-stretch 0.3s ease;
}
@media (prefers-reduced-motion: reduce) {
animation: none;
svg {
animation: none;
}
}
}
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
@keyframes stretch-on {
50% {
transform: scaleX(1.5);
}
}
@keyframes stretch-off {
50% {
transform: scaleX(1.5);
}
}
@keyframes counter-stretch {
50% {
transform: scaleX(calc(1 / 1.5));
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | ResponsiveSize | required | Control size (track, dot and icons). Fixed or responsive. |
| aria-label | string | required | Accessible name of the button (icon-only: without it, a screen reader would announce nothing). |
| checked | boolean | required | On state (controlled). On is the light theme, with the sun on the dot. |
| onChange | (checked: boolean) => void | required | Callback with the new state on every change. |