# 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.

Source: https://docs.nasaqui.com/components/switchers

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/switchers.json
```

Standalone 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 own `DropdownMenu` (this is what `UserMenu` uses).
- **`useThemeLabels`** and **`THEME_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: [`UserMenu`](https://docs.nasaqui.com/components/user-menu) already includes both.
- Per-component appearance (density, brand): those are provider settings, not user preferences.

## Import

```tsx
import {
  ThemeSwitcher, ThemeToggle, LocaleSwitcher, ThemeMenuItems, LocaleMenuItems, useThemeLabels, THEME_OPTIONS,
} from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
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`

```ts
useThemeLabels(labels?: Partial<ThemeLabels>): ThemeLabels
```

Returns 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

```tsx
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

```tsx
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-label` and a tooltip; the group is named by `labels.group`.
- `ThemeToggle` is named for the action ("Switch to dark theme"), so its name changes after each press; the icons are `aria-hidden`.
- The sound is decoration only: nothing depends on hearing it, and it is off under reduced motion.
- The icon-only `LocaleSwitcher` is named `"<label>: <current language>"`.
- Language names carry `lang` so screen readers switch voice.
- **Localise:** `labels` (built-ins cover `en` and `ar`) and `LocaleSwitcher.label`.

## RTL & i18n

- Switching to an RTL locale flips the whole document via `NasaqProvider`; these controls only call `setLocale`.
- Each language is displayed in its own script and direction (`dir` from the locale entry).
- The radio check mark and menu align with logical properties.

## Styling & tokens

- `ThemeSwitcher`: `border-border`, `bg-card`; active option `data-pressed:bg-nq-selected`, focus `nq-focus`.
- `ThemeToggle`: the ghost icon `Button`; the icons swap with `opacity`, `rotate` and `scale` over 300ms `ease-nq` (no transition under reduced motion).
- Target `[data-slot=theme-switcher]` or `[data-slot=theme-toggle]`; extend with `className`.

## 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 `UserMenu` in the same view.
- **Don't** hard-code language names; they come from the provider's locale list.

## Related

- [user-menu](https://docs.nasaqui.com/components/user-menu) · [app-shell](https://docs.nasaqui.com/components/app-shell)

## Lab

https://docs.nasaqui.com/?path=/docs/components-actions-switchers--docs

## Code

### React

```tsx
import { LocaleSwitcher, ThemeToggle } from "@fadymondy/nasaq/web";

export function Preferences() {
  return (
    <div className="flex items-center gap-3">
      <ThemeToggle />
      <LocaleSwitcher showLabel />
    </div>
  );
}
```

### shadcn

```tsx
import { LocaleSwitcher, ThemeToggle } from "@/components/ui/switchers";

export function Preferences() {
  return (
    <div className="flex items-center gap-3">
      <ThemeToggle />
      <LocaleSwitcher showLabel />
    </div>
  );
}
```

### Vue

```vue
<script setup lang="ts">
import { NqLocaleSwitcher, NqThemeToggle } from "@fadymondy/nasaq/vue";
</script>

<template>
  <div class="flex items-center gap-3">
    <NqThemeToggle />
    <NqLocaleSwitcher show-label />
  </div>
</template>
```

### Blade

```blade
<div class="flex items-center gap-3">
    <x-nq::switchers.theme-toggle />
    <x-nq::switchers.locale-switcher show-label />
</div>
```

### HTML + Alpine

```html
<div class="flex items-center gap-3">
    <div x-data="nqThemePref()" class="contents">
    <div data-slot="tooltip" x-data="nqTooltip(600, false)" x-modelable="open" x-id="['nq-tooltip']" class="contents">
    <span data-slot="tooltip-trigger" class="contents"><button data-slot="theme-toggle"
     type="button"                         x-on:click="toggle()" x-bind:data-state="dark ? &#039;dark&#039; : &#039;light&#039;" x-bind:aria-label="dark ? `Switch to light theme` : `Switch to dark theme`" aria-label="Switch to dark theme" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-nq-hover size-control-sm p-0 relative overflow-hidden text-muted-foreground hover:text-foreground">
        <svg data-slot="icon" aria-hidden="true" x-bind:class="dark ? &#039;rotate-90 scale-50 opacity-0&#039; : &#039;rotate-0 scale-100 opacity-100&#039;" class="absolute inset-0 m-auto transition-[opacity,rotate,scale] duration-300 ease-nq motion-reduce:transition-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <circle cx="12" cy="12" r="4"/>
  <path d="M12 2v2"/>
  <path d="M12 20v2"/>
  <path d="m4.93 4.93 1.41 1.41"/>
  <path d="m17.66 17.66 1.41 1.41"/>
  <path d="M2 12h2"/>
  <path d="M20 12h2"/>
  <path d="m6.34 17.66-1.41 1.41"/>
  <path d="m19.07 4.93-1.41 1.41"/>
</svg>            <svg data-slot="icon" aria-hidden="true" x-bind:class="dark ? &#039;rotate-0 scale-100 opacity-100&#039; : &#039;-rotate-90 scale-50 opacity-0&#039;" class="absolute inset-0 m-auto transition-[opacity,rotate,scale] duration-300 ease-nq motion-reduce:transition-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/>
</svg></button></span>
    <template x-teleport="body">
        <div data-slot="tooltip-content" x-bind="popup" x-nq-presence="open" x-anchor.top.offset.6="triggerEl"
            class="z-50 max-w-64 rounded-control bg-foreground px-2 py-1 text-caption text-background transition-opacity duration-150 ease-nq data-starting-style:opacity-0 data-ending-style:opacity-0"><span x-text="dark ? 'Switch to light theme' : 'Switch to dark theme'">Switch to dark theme</span></div>
    </template>
</div>
</div>
    <div x-data="nqLocalePref(JSON.parse('{\u0022en\u0022:\u0022English\u0022,\u0022ar\u0022:\u0022\\u0627\\u0644\\u0639\\u0631\\u0628\\u064a\\u0629\u0022}'))" class="contents">
    <div data-slot="dropdown-menu" x-data="nqDropdownMenu(false)" x-modelable="open" class="contents"><button data-slot="dropdown-menu-trigger"
     type="button"                         x-bind="trigger" x-ref="trigger" class="inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-sans text-label transition-colors duration-150 ease-nq min-h-[var(--nq-touch-min,0px)] outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-nq-focus disabled:pointer-events-none disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-nq-hover h-control-sm px-2.5 text-muted-foreground">
        <svg data-slot="icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m5 8 6 6"/>
  <path d="m4 14 6-6 2-3"/>
  <path d="M2 5h12"/>
  <path d="M7 2h1"/>
  <path d="m22 22-5-10-5 10"/>
  <path d="M14 18h6"/>
</svg>            <span x-text="name" x-bind:lang="locale">English</span></button>
        <template x-teleport="body">
    <div data-slot="dropdown-menu-content" x-bind="popup" x-init="popupEl = $el" x-nq-presence="open" x-anchor.bottom-end.offset.4="$refs.trigger"
        class="z-50 overflow-hidden rounded-floating border border-border bg-popover p-1.5 text-popover-foreground shadow-floating outline-none max-h-[var(--available-height)] overflow-y-auto transition-opacity duration-150 ease-nq data-starting-style:opacity-0 data-ending-style:opacity-0 min-w-40"><div data-slot="dropdown-menu-group" role="group" ><div data-slot="dropdown-menu-label" role="presentation" class="px-2.5 pt-1.5 pb-1 text-caption font-medium text-muted-foreground">Language</div></div>
            <div data-slot="dropdown-menu-radio-group" role="group" x-data="{ value: null }" x-modelable="value" x-model="locale"><div data-slot="dropdown-menu-radio-item" role="menuitemradio" x-bind="item"
    x-on:click="if (! $el.hasAttribute('data-disabled')) value = 'en'"
    x-bind:aria-checked="value === 'en' ? 'true' : 'false'" x-bind:data-checked="value === 'en' ? '' : undefined" x-bind:data-unchecked="value === 'en' ? undefined : ''"
     data-keep-open         class="relative flex h-nav-row min-h-[var(--nq-touch-min,0px)] cursor-default select-none items-center gap-2.5 rounded-control px-2.5 text-body-sm text-foreground outline-none data-highlighted:bg-nq-selected data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground ps-8">
    <span aria-hidden="true" class="absolute start-2.5 inline-flex size-4 items-center justify-center">
        <span x-show="value === 'en'" style="display:none" class="block size-1.5 rounded-full bg-current"></span>
    </span>
    <span lang="en" dir="ltr">English</span>
</div>
            <div data-slot="dropdown-menu-radio-item" role="menuitemradio" x-bind="item"
    x-on:click="if (! $el.hasAttribute('data-disabled')) value = 'ar'"
    x-bind:aria-checked="value === 'ar' ? 'true' : 'false'" x-bind:data-checked="value === 'ar' ? '' : undefined" x-bind:data-unchecked="value === 'ar' ? undefined : ''"
     data-keep-open         class="relative flex h-nav-row min-h-[var(--nq-touch-min,0px)] cursor-default select-none items-center gap-2.5 rounded-control px-2.5 text-body-sm text-foreground outline-none data-highlighted:bg-nq-selected data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground ps-8">
    <span aria-hidden="true" class="absolute start-2.5 inline-flex size-4 items-center justify-center">
        <span x-show="value === 'ar'" style="display:none" class="block size-1.5 rounded-full bg-current"></span>
    </span>
    <span lang="ar" dir="rtl">العربية</span>
</div></div></div>
</template></div>
</div>
</div>
```
