feat(docs): add system theme option to theme toggle (#324)

* feat(docs): add system theme option to theme toggle

* fix(docs): address review on theme toggle

Render the switcher as a proper radio group (role="radiogroup"/role="radio"
with aria-checked, roving tabindex, and arrow-key navigation) instead of
independent aria-pressed toggles, since the three options are mutually
exclusive. Update the component docstring to describe the three-option
switcher and note that it reads `theme` (not `resolvedTheme`) so the System
option can show as selected. Drop unrelated formatting churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Luca Martial <lucamrtl@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Luca Martial <48870843+luca-martial@users.noreply.github.com>
This commit is contained in:
Bittu kumar 2026-06-29 06:11:59 +05:30 committed by GitHub
parent 50afcae9f4
commit 4f084186f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,14 +1,24 @@
"use client"; "use client";
import { useEffect, useState, type ComponentProps, type SVGProps } from "react"; import {
useEffect,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type SVGProps,
} from "react";
import { useTheme } from "fumadocs-ui/provider/base"; import { useTheme } from "fumadocs-ui/provider/base";
/** /**
* Two-icon theme switcher (light / dark), each icon selecting its own theme * Three-icon theme switcher (light / system / dark) rendered as a radio group
* unlike fumadocs' default "light-dark" switcher, which is a single blind * each icon selects its own theme, unlike fumadocs' default "light-dark"
* toggle that flips on any click. Dropped into the sidebar footer pill via * switcher, which is a single blind toggle that flips on any click. Reads
* `slots.themeSwitch`, so fumadocs passes the container className (left * `theme`, not `resolvedTheme`, so the "system" option can show as selected
* divider, `ms-auto`, rounded inner buttons); we merge it onto our own base. * (resolvedTheme collapses system to light/dark). Dropped into the sidebar
* footer pill via `slots.themeSwitch`, so fumadocs passes the container
* className (left divider, `ms-auto`, rounded inner buttons); we merge it onto
* our own base.
* *
* Icons are inlined (the project doesn't depend on `lucide-react` directly); * Icons are inlined (the project doesn't depend on `lucide-react` directly);
* `useTheme` is re-exported by fumadocs so we avoid a bare `next-themes` import. * `useTheme` is re-exported by fumadocs so we avoid a bare `next-themes` import.
@ -38,6 +48,25 @@ function SunIcon(props: SVGProps<SVGSVGElement>) {
); );
} }
function MonitorIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8" />
<path d="M12 16v4" />
</svg>
);
}
function MoonIcon(props: SVGProps<SVGSVGElement>) { function MoonIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
@ -57,6 +86,7 @@ function MoonIcon(props: SVGProps<SVGSVGElement>) {
const OPTIONS = [ const OPTIONS = [
["light", SunIcon], ["light", SunIcon],
["system", MonitorIcon],
["dark", MoonIcon], ["dark", MoonIcon],
] as const; ] as const;
@ -65,23 +95,53 @@ function cx(...classes: (string | false | undefined)[]): string {
} }
export function ThemeToggle({ className, ...props }: ComponentProps<"div">) { export function ThemeToggle({ className, ...props }: ComponentProps<"div">) {
const { setTheme, resolvedTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
const active = mounted ? resolvedTheme : null; const active = mounted ? theme : null;
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
// Pre-mount nothing is selected, so keep the first control tabbable.
const selectedIndex = OPTIONS.findIndex(([key]) => key === active);
const rovingIndex = selectedIndex === -1 ? 0 : selectedIndex;
// Radio-group keyboard model: arrows move focus and pick that theme.
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
const delta =
event.key === "ArrowRight" || event.key === "ArrowDown"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp"
? -1
: 0;
if (delta === 0) return;
event.preventDefault();
const next = (index + delta + OPTIONS.length) % OPTIONS.length;
setTheme(OPTIONS[next][0]);
buttonsRef.current[next]?.focus();
}
return ( return (
<div <div
className={cx("inline-flex items-center overflow-hidden border", className)} className={cx("inline-flex items-center overflow-hidden border", className)}
data-theme-toggle="" data-theme-toggle=""
role="radiogroup"
aria-label="Theme"
{...props} {...props}
> >
{OPTIONS.map(([key, Icon]) => ( {OPTIONS.map(([key, Icon], index) => (
<button <button
key={key} key={key}
ref={(el) => {
buttonsRef.current[index] = el;
}}
type="button" type="button"
role="radio"
aria-label={key} aria-label={key}
aria-checked={active === key}
tabIndex={index === rovingIndex ? 0 : -1}
onClick={() => setTheme(key)} onClick={() => setTheme(key)}
onKeyDown={(event) => onKeyDown(event, index)}
className={cx( className={cx(
"size-6.5 p-1.5 transition-colors", "size-6.5 p-1.5 transition-colors",
active === key active === key