Skip to content

Color scheme icon <l-color-scheme-icon>

A sun that morphs into a moon.

HTML tag<l-color-scheme-icon>
Native
Progressive
Plain
Shadow-DOM
Shadow-DOM Custom Element
html
<l-color-scheme-icon scheme="dark"></l-color-scheme-icon>

The two glyphs are one shape: the disc grows, a mask slides in to carve the crescent, and the rays retract in a ripple. Nothing is added or removed, so the change reads as a single object turning rather than two icons swapping.

Its home is an icon-only button in a header — the button owns the role, the name and the pressed state; the glyph shows which scheme is in effect.

Code
html
<header
  class="flex w-full items-center justify-between gap-4 rounded-xl border px-4 py-3 border-[var(--l-color-border)] bg-[var(--l-color-surface)]"
>
  <div class="flex items-center gap-6">
    <span class="text-sm font-semibold text-[var(--l-color-text-primary)]">Acme</span>

    <nav class="flex gap-4 text-sm text-[var(--l-color-text-secondary)]">
      <a href="#">Dashboard</a>
      <a href="#">Reports</a>
      <a href="#">Settings</a>
    </nav>
  </div>

  <button
    class="l-button"
    data-icon-only
    aria-pressed="false"
    aria-label="Dark theme"
  >
    <l-color-scheme-icon></l-color-scheme-icon>
  </button>
</header>

It is presentational only — it shows a scheme, it never chooses or stores one. With no scheme it follows colorScheme.current, so it stays in step with every other control on the page without wiring; set scheme to pin it.

The example above uses inline handlers to stay readable. In an application, the click and the pressed state are what you wire — the glyph needs nothing:

js
import { colorScheme } from 'luxen-ui/color-scheme';

const button = document.querySelector('[aria-label="Dark theme"]');

button.addEventListener('click', () => colorScheme.toggle());

// Called once with the current scheme, then on every change — no initial pass.
colorScheme.subscribe((scheme) => {
  button.setAttribute('aria-pressed', String(scheme === 'dark'));
});

Which scheme does the glyph show?

The one in effect, not the one a click would move to: dark mode shows the moon. That is what makes it correct inside a role="switch" thumb or beside an aria-pressed button, where the state is already announced and the glyph should agree with it.

To advertise the destination instead — a sun meaning "switch to light" — pass the opposite of your current scheme. Pick one convention and hold it across the app; mixing them is how a toggle stops being readable.

Options

Scheme

Leave scheme unset and the icon follows the page's color scheme — a stored override, else the OS preference — updating itself when it changes anywhere, including in another tab.

Set scheme and the store is out of the picture: the glyph shows what you tell it and nothing else moves it. That is the hook for an app that already owns its light/dark state — a framework store, a class on <html> written by the server, a user setting fetched from an API — and also what pins a glyph in a legend or a preview.

js
// Your state, your rules. The icon just draws it.
theme.subscribe((value) => (icon.scheme = value));
scheme="light"
scheme="dark"
follows the page
Code
html
<div class="flex items-center gap-8">
  <div class="flex flex-col items-center gap-2">
    <l-color-scheme-icon
      scheme="light"
      class="[--size:2rem]"
    ></l-color-scheme-icon>
    <code class="text-xs text-[var(--l-color-text-secondary)]">scheme="light"</code>
  </div>

  <div class="flex flex-col items-center gap-2">
    <l-color-scheme-icon
      scheme="dark"
      class="[--size:2rem]"
    ></l-color-scheme-icon>
    <code class="text-xs text-[var(--l-color-text-secondary)]">scheme="dark"</code>
  </div>

  <div class="flex flex-col items-center gap-2">
    <l-color-scheme-icon class="[--size:2rem]"></l-color-scheme-icon>
    <code class="text-xs text-[var(--l-color-text-secondary)]">follows the page</code>
  </div>
</div>

Size & color

--size sets width and height (default 1em, so it scales with the surrounding text). --color defaults to currentColor.

Code
html
<div class="flex items-center gap-8">
  <l-color-scheme-icon scheme="dark"></l-color-scheme-icon>

  <l-color-scheme-icon
    scheme="dark"
    class="[--size:2rem]"
  ></l-color-scheme-icon>

  <l-color-scheme-icon
    scheme="dark"
    class="[--size:3rem] [--color:var(--l-color-text-info)]"
  ></l-color-scheme-icon>
</div>

Accessible name

The icon is decorative by default and hidden from assistive tech, which is what you want inside a row or button that already carries the name. Set label to make it meaningful — it then exposes role="img" with that name.

Both icons below look identical; the difference exists only in the accessibility tree.

Code
html
<div class="flex items-center gap-8">
  <button
    class="l-button"
    data-icon-only
    aria-label="Dark theme"
  >
    <l-color-scheme-icon></l-color-scheme-icon>
  </button>

  <l-color-scheme-icon
    label="Currently in dark theme"
    scheme="dark"
    class="[--size:2rem]"
  ></l-color-scheme-icon>
</div>

Examples

In a menu row

Put it in a type="checkbox" dropdown item and the row is the control: whole row clickable, menuitemcheckbox role, menu stays open. The icon stays decorative.

Add check-placement="end" so the check moves to the trailing edge and leaves the leading column free — the glyph then lines up with every other row's icon.

Jane Cooper jane.cooper@acme.com
Dark theme Compact rows Sign out
Code
html
<header
  class="flex w-full items-center justify-between gap-4 rounded-xl border px-4 py-3 border-[var(--l-color-border)] bg-[var(--l-color-surface)]"
>
  <div class="flex items-center gap-6">
    <span class="text-sm font-semibold text-[var(--l-color-text-primary)]">Acme</span>

    <nav class="flex gap-4 text-sm text-[var(--l-color-text-secondary)]">
      <a href="#">Dashboard</a>
      <a href="#">Reports</a>
    </nav>
  </div>

  <l-dropdown placement="bottom-end">
    <l-avatar
      slot="trigger"
      interactive
      name="Jane Cooper"
      aria-label="Account"
      class="[--appearance:circle] [--color:var(--l-color-blue-200)]"
    ></l-avatar>

    <div
      slot="header"
      class="flex flex-col px-2 py-1.5"
    >
      <span class="text-sm font-medium text-[var(--l-color-text-primary)]">Jane Cooper</span>
      <span class="text-xs text-[var(--l-color-text-secondary)]">jane.cooper@acme.com</span>
    </div>

    <l-divider></l-divider>

    <l-dropdown-item
      type="checkbox"
      check-placement="end"
      value="theme"
    >
      <l-color-scheme-icon slot="prefix"></l-color-scheme-icon>
      Dark theme
    </l-dropdown-item>

    <l-dropdown-item
      type="checkbox"
      check-placement="end"
      value="compact"
      checked
    >
      <iconify-icon
        slot="prefix"
        icon="lucide:rows-3"
      ></iconify-icon>
      Compact rows
    </l-dropdown-item>

    <l-divider></l-divider>

    <l-dropdown-item value="signout">
      <iconify-icon
        slot="prefix"
        icon="lucide:log-out"
      ></iconify-icon>
      Sign out
    </l-dropdown-item>
  </l-dropdown>
</header>

Here the row carries the state, so checked is what you keep in step:

js
import { colorScheme } from 'luxen-ui/color-scheme';

const item = document.querySelector('l-dropdown-item[value="theme"]');

document.querySelector('l-dropdown').addEventListener('select', (event) => {
  if (event.item === item) colorScheme.set(item.checked ? 'dark' : 'light');
});

colorScheme.subscribe((scheme) => (item.checked = scheme === 'dark'));

Do not put a switch in a menu row instead: a role="menu" may only own menuitem, menuitemcheckbox and menuitemradio children, and a nested control gives the row two competing click targets.

Accessibility

Criteria

Decorative by default

With no label the icon is aria-hidden — the surrounding row or button carries the accessible name, so nothing is announced twice

WCAG1.1.1
RGAA1.2
Meaningful when named

Setting label exposes role=img with that name, for the rare case where the glyph stands alone

WCAG1.1.1
RGAA1.1
Reduced motion

The morph collapses to 0ms under prefers-reduced-motion; the glyph still changes, it just does not animate

WCAG2.3.3

API reference

Importing

js
import 'luxen-ui/color-scheme-icon';

Properties

schemeColorScheme | undefinedProperty
Which glyph to show. Leave it unset and the icon follows the page's scheme (colorScheme.current — a stored override, else the OS preference), so it stays in step with every other control without wiring. Set it to pin the glyph, for a legend or a preview that must not move.
labelstring | undefinedProperty
Accessible label. When set, the glyph becomes meaningful (role="img"); when absent it is decorative and hidden from assistive tech — the right default inside a row or button that already carries the name.

CSS custom properties

--sizedefault:1emCustom property
Width and height of the glyph.
--colordefault:currentColorCustom property
Glyph color.

CSS parts

basePart
The root <svg>, for overriding the morph's timing or tilt.