ThemeSwitcher
Theme (light / dark / system) and language controls - a segmented switcher, a one-icon light/dark toggle with a click, a language dropdown, and menu-item variants for embedding in other menus.
Code
import { LocaleSwitcher, ThemeToggle } from "@fadymondy/nasaq/web";export function Preferences() { return ( <div className="flex items-center gap-3"> <ThemeToggle /> <LocaleSwitcher showLabel /> </div> );}Actions · stable
Live examples and controls: ThemeSwitcher in the lab.
Install
npx shadcn@latest add https://docs.nasaqui.com/r/switchers.jsonStandalone controls for the two global preferences. Both talk to NasaqProvider (useNasaq()), so
choosing Arabic flips the whole document to RTL and choosing a theme updates every token.
ThemeSwitcher: a segmented Light / Dark / System control.ThemeToggle: one icon button that flips light and dark, with an animated sun/moon swap and a soft click.LocaleSwitcher: a language dropdown, icon-only or with the current language name.ThemeMenuItems/LocaleMenuItems: the same choices as radio items for use inside your ownDropdownMenu(this is whatUserMenuuses).useThemeLabelsandTHEME_OPTIONS: the localised labels and option list.
When to use
- Login, marketing or settings pages that need a visible theme or language control.
- A header where the user menu is not present.
When not to use
- Inside the app shell's sidebar:
UserMenualready includes both. - Per-component appearance (density, brand): those are provider settings, not user preferences.
Import
import {
ThemeSwitcher, ThemeToggle, LocaleSwitcher, ThemeMenuItems, LocaleMenuItems, useThemeLabels, THEME_OPTIONS,
} from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"Quick start
import { LocaleSwitcher, ThemeToggle } from "@fadymondy/nasaq/web";
export function Preferences() {
return (
<div className="flex items-center gap-3">
<ThemeToggle />
<LocaleSwitcher showLabel />
</div>
);
}Must render inside NasaqProvider.
Anatomy
ThemeSwitcher data-slot="theme-switcher" (ToggleGroup, aria-label = labels.group)
└─ Toggle × 3 Sun / Moon / Monitor, tooltip + aria-label; data-pressed on the active one
ThemeToggle data-slot="theme-toggle", data-state="light" | "dark" (ghost Button, icon-sm)
├─ Sun shown in light; rotates out on switch
└─ Moon shown in dark; rotates in
LocaleSwitcher DropdownMenu
├─ trigger ghost Button (icon-sm, or sm with the language name)
└─ menu label, one radio item per locale (`DropdownMenuRadioItem`, the current one is checked and exposed to assistive tech)API
ThemeSwitcher
Extends Omit<ComponentProps<typeof ToggleGroup>, "value" | "onValueChange"> (so className and other group props apply).
| Prop | Type | Default | Description |
|---|---|---|---|
labels? | Partial<ThemeLabels> | EN/AR by locale | Overrides for light, dark, system, group. |
The value is read from and written to useNasaq() (theme, setTheme). Clicking the active option does nothing (one option is always selected).
ThemeToggle
Extends the Button props except onClick and children.
| Prop | Type | Default | Description |
|---|---|---|---|
labels? | Partial<ThemeLabels> | EN/AR by locale | Overrides for toDark and toLight (the button's name and tooltip). |
sound? | boolean | true | A soft click on switch. Always silent under prefers-reduced-motion. |
Reads resolvedTheme and sets an explicit "light" or "dark", so the first click leaves "System". Use it where
space is tight (auth pages, marketing headers); use ThemeSwitcher or ThemeMenuItems where people should be able
to go back to "System". The click is "Metal click" by Kenney (CC0), bundled as a data URI (about 5 KB) and played
through Web Audio; it never throws if the browser blocks audio.
LocaleSwitcher
| Prop | Type | Default | Description |
|---|---|---|---|
showLabel? | boolean | false | Shows the current language name beside the icon. |
label? | string | "Language" / "اللغة" | Menu heading and, icon-only, the button's accessible name prefix ("Language: English"). |
className? | string | none | Classes for the trigger button. |
Options come from useNasaq().locales; each is shown in its own script with lang and dir set.
ThemeMenuItems
({ labels?: Partial<ThemeLabels> }) => JSX.Element: a DropdownMenuRadioGroup with Light, Dark, System. Use inside a DropdownMenuContent or DropdownMenuSubContent.
LocaleMenuItems
() => JSX.Element: a DropdownMenuRadioGroup of the provider's locales.
useThemeLabels
useThemeLabels(labels?: Partial<ThemeLabels>): ThemeLabelsReturns the built-in labels for the provider locale (Arabic for ar*, otherwise English), merged with labels.
ThemeLabels
{ light; dark; system; group; toDark; toLight } (all strings). Defaults: EN "Light", "Dark", "System", "Theme", "Switch to dark theme", "Switch to light theme"; AR "فاتح", "داكن", "النظام", "المظهر", "التبديل إلى المظهر الداكن", "التبديل إلى المظهر الفاتح".
THEME_OPTIONS
readonly [{ value: "light"; icon }, { value: "dark"; icon }, { value: "system"; icon }], typed against ThemePreference from @nasaq/tokens.
Examples
In your own dropdown
import {
Button, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuTrigger,
LocaleMenuItems, ThemeMenuItems, useThemeLabels,
} from "@fadymondy/nasaq/web";
export function PrefsMenu() {
const t = useThemeLabels();
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="sm" />}>Preferences</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>{t.group}</DropdownMenuLabel>
<ThemeMenuItems />
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuLabel>Language</DropdownMenuLabel>
<LocaleMenuItems />
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}Custom labels
import { ThemeSwitcher } from "@fadymondy/nasaq/web";
export const Custom = () => <ThemeSwitcher labels={{ group: "Appearance", system: "Auto" }} />;Accessibility
ThemeSwitcher (toggle group):
| Key | Action |
|---|---|
Tab | Focus the group. |
← → | Move between Light / Dark / System (follows reading order in RTL). |
Space / Enter | Select the focused option. |
LocaleSwitcher (menu):
| Key | Action |
|---|---|
Enter / Space / ↓ on trigger | Opens the list. |
↑ ↓ | Move between languages. |
Enter | Switches language. |
Esc | Closes. |
- Each theme toggle has an
aria-labeland a tooltip; the group is named bylabels.group. ThemeToggleis named for the action ("Switch to dark theme"), so its name changes after each press; the icons arearia-hidden.- The sound is decoration only: nothing depends on hearing it, and it is off under reduced motion.
- The icon-only
LocaleSwitcheris named"<label>: <current language>". - Language names carry
langso screen readers switch voice. - Localise:
labels(built-ins coverenandar) andLocaleSwitcher.label.
RTL & i18n
- Switching to an RTL locale flips the whole document via
NasaqProvider; these controls only callsetLocale. - Each language is displayed in its own script and direction (
dirfrom the locale entry). - The radio check mark and menu align with logical properties.
Styling & tokens
ThemeSwitcher:border-border,bg-card; active optiondata-pressed:bg-nq-selected, focusnq-focus.ThemeToggle: the ghost iconButton; the icons swap withopacity,rotateandscaleover 300msease-nq(no transition under reduced motion).- Target
[data-slot=theme-switcher]or[data-slot=theme-toggle]; extend withclassName.
Do / Don't
- Do offer "System" as the default preference.
- Do use the menu-item variants inside menus so state is exposed as radios.
- Don't duplicate the controls beside
UserMenuin the same view. - Don't hard-code language names; they come from the provider's locale list.
Related
Lab
https://docs.nasaqui.com/?path=/docs/components-actions-switchers--docs
ShareButton
A share button and dialog with copy link, email invites with a role, link access of restricted or anyone with the link, link expiry, the people who already have access and the native Web Share sheet where the browser offers it.
ToggleGroup
Pressed-state buttons grouped as a segmented control, single or multiple selection, plus a standalone Toggle.