- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
The preview uses local state. For an application, wrap your app in a next-themes provider and use ThemeToggle without value.
Variants
variant="icon" renders a single outline icon button with a tooltip and a D key hint. It flips the theme with the same circular reveal as the other variants. The D shortcut itself is wired by the host app.
Installation
$ pnpm dlx shadcn@latest add @cobalt/theme-toggle
Source
"use client";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { RiMoonLine, RiSunLine } from "@remixicon/react";
import { cn } from "cn";
import { useTheme } from "next-themes";
import * as React from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { Switch } from "@/components/ui/switch";
type ThemeMode = "light" | "dark";
interface Origin {
x: number;
y: number;
}
type TransitionDocument = Document & {
startViewTransition?: (update: () => void) => {
ready: Promise<void>;
finished: Promise<void>;
};
};
let transitionRunning = false;
export interface ThemeTransitionOptions {
/** The control the reveal grows from, when there is no `point`. */
element?: HTMLElement | null;
/** Viewport coordinates of the click. Without one, the element or the viewport centre is used. */
point?: Origin | null;
duration?: number;
}
/**
* Runs `update` inside the circular view-transition reveal. Without the View
* Transitions API, or under reduced motion, `update` runs at once.
*/
export function runThemeTransition(
update: () => void,
{ element, point, duration = 820 }: ThemeTransitionOptions = {}
) {
if (transitionRunning) {
return;
}
const doc = document as TransitionDocument;
if (
!doc.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
update();
return;
}
const rect = element?.getBoundingClientRect();
const x = Math.min(
Math.max(
point?.x ?? (rect ? rect.left + rect.width / 2 : innerWidth / 2),
0
),
innerWidth
);
const y = Math.min(
Math.max(
point?.y ?? (rect ? rect.top + rect.height / 2 : innerHeight / 2),
0
),
innerHeight
);
const radius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y)
);
const length = radius * 2.5;
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><filter id="blur" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2" /></filter></defs><circle cx="50" cy="50" r="42" fill="white" filter="url(#blur)" /></svg>';
const style = document.createElement("style");
// Named elements (a sticky header, a footer) are lifted out of the root
// snapshot and would cross-fade on their own timing instead of being
// revealed by the mask. Unnamed for the length of this transition, they
// change together with the page.
style.textContent = `html *{view-transition-name:none!important}::view-transition-old(root){animation:none;mix-blend-mode:normal}::view-transition-new(root){mix-blend-mode:normal;-webkit-mask-image:url("data:image/svg+xml,${encodeURIComponent(svg)}");mask-image:url("data:image/svg+xml,${encodeURIComponent(svg)}");-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;animation:theme-toggle-reveal ${duration}ms cubic-bezier(0.16,1,0.3,1) both;transform-origin:${x}px ${y}px;will-change:mask-size,mask-position}@keyframes theme-toggle-reveal{from{mask-position:${x}px ${y}px;mask-size:0 0;-webkit-mask-position:${x}px ${y}px;-webkit-mask-size:0 0}to{mask-position:${x - length / 2}px ${y - length / 2}px;mask-size:${length}px ${length}px;-webkit-mask-position:${x - length / 2}px ${y - length / 2}px;-webkit-mask-size:${length}px ${length}px}}`;
document.head.append(style);
transitionRunning = true;
let applied = false;
try {
const transition = doc.startViewTransition(() => {
flushSync(() => {
update();
applied = true;
});
});
void transition.ready
.then(() => transition.finished)
.catch(() => {
if (!applied) {
update();
}
})
.finally(() => {
style.remove();
transitionRunning = false;
});
} catch {
if (!applied) {
update();
}
style.remove();
transitionRunning = false;
}
}
export interface ThemeToggleProps {
variant?:
| "icon"
| "sidebar"
| "segmented"
| "sidebar-segmented"
| "glass-segmented";
collapsed?: boolean;
className?: string;
size?: "sm" | "default";
transitionDuration?: number;
value?: ThemeMode;
onValueChange?: (value: ThemeMode) => void;
/**
* With `value`, still draw the thumb and icons from the `.dark` class on
* `html`, which is set before first paint. `value` then only drives
* `aria-pressed` and click handling.
*/
documentTheme?: boolean;
}
function ThemeToggle({
variant = "sidebar",
collapsed = false,
className,
size = "default",
transitionDuration = 820,
value,
onValueChange,
documentTheme = false,
}: ThemeToggleProps) {
const id = React.useId();
const { resolvedTheme, setTheme } = useTheme();
// next-themes only knows the stored theme on the client, so the first client
// render must match the server (light). `settled` keeps the thumb from
// sliding to the stored theme on load.
const [mounted, setMounted] = React.useState(false);
const [settled, setSettled] = React.useState(false);
React.useEffect(() => {
setMounted(true);
let second = 0;
const first = requestAnimationFrame(() => {
second = requestAnimationFrame(() => setSettled(true));
});
return () => {
cancelAnimationFrame(first);
cancelAnimationFrame(second);
};
}, []);
const theme =
value ?? (mounted && resolvedTheme === "dark" ? "dark" : "light");
const dark = theme === "dark";
// Uncontrolled toggles follow the html class, so the first paint is right.
const fromClass = value === undefined || documentTheme;
const origin = React.useRef<Origin | null>(null);
const control = React.useRef<HTMLElement | null>(null);
function change(
next: ThemeMode,
element: HTMLElement | null,
point: Origin | null
) {
if (next === theme) {
return;
}
const update = () => {
if (value === undefined) {
setTheme(next);
}
onValueChange?.(next);
};
// Controlled previews do not animate the document or change the host theme.
if (value !== undefined) {
update();
return;
}
runThemeTransition(update, {
element,
point,
duration: transitionDuration,
});
}
if (variant === "icon") {
return (
<Button
data-slot="theme-toggle"
variant="outline"
size="icon"
aria-label="Toggle theme"
className={className}
onClick={(event) =>
change(
dark ? "light" : "dark",
event.currentTarget,
event.clientX === 0 && event.clientY === 0
? null
: { x: event.clientX, y: event.clientY }
)
}
tooltip={
<>
Toggle Mode
<Kbd>D</Kbd>
</>
}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="size-4"
aria-hidden="true"
>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 3l0 18" />
<path d="M12 9l4.65 -4.65" />
<path d="M12 14.3l7.37 -7.37" />
<path d="M12 19.6l8.85 -8.85" />
</svg>
</Button>
);
}
if (variant !== "sidebar") {
const glass = variant === "glass-segmented";
const small = size === "sm" && !glass;
const sidebar = variant === "sidebar-segmented";
return (
<div
data-slot="theme-toggle"
role="group"
aria-label="Theme"
className={cn(
"relative inline-flex w-fit items-center gap-1 rounded-full p-1",
glass
? "bg-transparent p-0.5 sm:p-1"
: sidebar
? "bg-[var(--surface-strong,var(--muted))]"
: "bg-[var(--surface-subtle,var(--muted))]",
small && "gap-0.5 p-0.5",
className
)}
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute top-1 start-1 size-8 rounded-full shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] transition-transform duration-200 ease-[ease] motion-reduce:transition-none",
glass
? "top-0.5 start-0.5 size-7 bg-[var(--theme-toggle-glass-selected,var(--background))] sm:top-1 sm:start-1 sm:size-8"
: sidebar
? "bg-[var(--theme-toggle-sidebar-selected,var(--popover))]"
: "bg-[var(--surface-raised,var(--popover))]",
small && "top-0.5 start-0.5 size-6",
!settled && "transition-none",
fromClass
? small
? "in-[.dark]:translate-x-6.5 rtl:in-[.dark]:-translate-x-6.5"
: glass
? "in-[.dark]:translate-x-8 rtl:in-[.dark]:-translate-x-8 sm:in-[.dark]:translate-x-9 sm:rtl:in-[.dark]:-translate-x-9"
: "in-[.dark]:translate-x-9 rtl:in-[.dark]:-translate-x-9"
: dark &&
(small
? "translate-x-6.5 rtl:-translate-x-6.5"
: glass
? "translate-x-8 rtl:-translate-x-8 sm:translate-x-9 sm:rtl:-translate-x-9"
: "translate-x-9 rtl:-translate-x-9")
)}
/>
{(["light", "dark"] as const).map((mode) => {
const selected = theme === mode;
const Icon = mode === "light" ? RiSunLine : RiMoonLine;
return (
<ButtonPrimitive
key={mode}
type="button"
aria-label={`Use ${mode} mode`}
aria-pressed={selected}
title={`${mode === "light" ? "Light" : "Dark"} mode`}
onClick={(event) =>
change(
mode,
event.currentTarget,
event.clientX === 0 && event.clientY === 0
? null
: { x: event.clientX, y: event.clientY }
)
}
className={cn(
"relative z-10 grid size-8 cursor-pointer place-items-center rounded-full text-[color:var(--foreground-secondary,var(--muted-foreground))] outline-none hover:text-foreground hover:transition-colors hover:duration-150 hover:ease-[ease] focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
fromClass
? mode === "light"
? "text-foreground in-[.dark]:text-[color:var(--foreground-secondary,var(--muted-foreground))]"
: "in-[.dark]:text-foreground"
: selected && "text-foreground",
glass && "size-7 sm:size-8",
small && "size-6"
)}
>
<Icon
aria-hidden="true"
className={small ? "size-3.5" : "size-4"}
/>
</ButtonPrimitive>
);
})}
</div>
);
}
if (collapsed) {
const Icon = dark ? RiSunLine : RiMoonLine;
return (
<ButtonPrimitive
data-slot="theme-toggle"
type="button"
aria-label={dark ? "Use light mode" : "Use dark mode"}
aria-pressed={dark}
title={dark ? "Light mode" : "Dark mode"}
onClick={(event) =>
change(
dark ? "light" : "dark",
event.currentTarget,
event.clientX === 0 && event.clientY === 0
? null
: { x: event.clientX, y: event.clientY }
)
}
className={cn(
"flex size-9 cursor-pointer items-center justify-center rounded-[10px] text-[color:var(--foreground-secondary,var(--muted-foreground))] outline-none transition-colors duration-150 ease-[ease] hover:bg-[var(--surface-strong,var(--muted))] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
className
)}
>
{fromClass ? (
<>
<RiMoonLine
className="size-5 in-[.dark]:hidden"
aria-hidden="true"
/>
<RiSunLine
className="hidden size-5 in-[.dark]:block"
aria-hidden="true"
/>
</>
) : (
<Icon className="size-5" aria-hidden="true" />
)}
</ButtonPrimitive>
);
}
return (
<label
htmlFor={id}
data-slot="theme-toggle"
className={cn(
"flex w-full cursor-pointer items-center justify-between rounded-[10px] p-2 transition-colors duration-150 ease-[ease] hover:bg-[var(--surface-strong,var(--muted))] has-focus-visible:ring-2 has-focus-visible:ring-inset has-focus-visible:ring-ring motion-reduce:transition-none",
className
)}
>
<span className="flex min-w-0 items-center gap-2">
<RiMoonLine
className="size-5 shrink-0 text-[color:var(--foreground-secondary,var(--muted-foreground))]"
aria-hidden="true"
/>
<span className="text-sm leading-5 font-medium text-[color:var(--foreground-secondary,var(--muted-foreground))]">
Dark mode
</span>
</span>
<Switch
id={id}
ref={control}
size="sm"
checked={dark}
aria-label="Dark mode"
onPointerDown={(event) => {
origin.current = { x: event.clientX, y: event.clientY };
}}
onCheckedChange={(checked) => {
change(checked ? "dark" : "light", control.current, origin.current);
origin.current = null;
}}
/>
</label>
);
}
export { ThemeToggle };
export type { Origin as ThemeTransitionPoint }; On this page