block

Appearance

A pre-built block that lets people change how the site looks: light or dark, a color palette, density and, opt-in, how round the corners are. By default it is an icon button that opens a popover; AppearancePanel renders the same controls inline for a settings page.

The choice is saved, re-applied on the next visit before first paint (see Head script), and kept in sync with every other Appearance or Theme Mode Select on the page and in other tabs.

Default

---
// GENERATED from packages/demos/appearance/default.demo.tsx — edit the source, not this file.
import AppearancePicker from "@pindoba/astro-appearance";
import { flex } from "@pindoba/styled-system/patterns";
---

<div class={flex({ justify: "center" })}>
  <AppearancePicker />
</div>

Settings page

AppearancePanel is the panel on its own. allowCustom adds a custom-color button that opens a color picker, and, with surface, a Neutral/Tinted choice for the surfaces under it. This one also opts into the Corners section.

---
// GENERATED from packages/demos/appearance/settings-panel.demo.tsx — edit the source, not this file.
import { AppearancePanel } from "@pindoba/astro-appearance";
import { css } from "@pindoba/styled-system/css";
---

<div class={css({ width: "fit-content" })}>
  <AppearancePanel
    palettes={[
      {
        id: "ocean",
        label: "Ocean",
        primary: "#0e7490",
        surface: "tinted",
      },
      {
        id: "forest",
        label: "Forest",
        primary: "#15803d",
        surface: "tinted",
      },
      {
        id: "violet",
        label: "Violet",
        primary: "#7c3aed",
        surface: "tinted",
      },
      { id: "rose", label: "Rose", primary: "#e11d48" },
    ]}
    allowCustom={{ primary: true, surface: true }}
    sections={["mode", "palette", "density", "radius"]}
  />
</div>

Palettes

Each palette is a primary seed color plus a rule for the surfaces:

const palettes = [
  { id: "ocean", label: "Ocean", primary: "#0e7490", surface: "tinted" },
  { id: "rose", label: "Rose", primary: "#e11d48" }, // surfaces stay neutral
];

Only the seed’s hue and chroma are used. Lightness follows the theme, so every text color keeps WCAG AA on the new surfaces in both modes. surface takes:

  • "neutral" (the default) — the theme’s own neutral.
  • "tinted" — the primary’s hue at a subtle chroma.
  • { tint: 0–1 } — how much of the neutral chroma ceiling the surfaces take.
  • a color — its hue, with the chroma capped so surfaces stay neutral.

A palette can also name a theme color family ({ primary, family: "amber" }): it is then generated with that family’s own tuning, its lightness curve and the direction of the text on its fills, instead of from the seed alone. Amber, yellow and lime use it: their fills are bright with dark text, which a seed can’t express.

The first swatch is always the theme’s own colors. Without palettes, the block offers every color family the theme ships, around the hue wheel from red to rose. Each sits on the tinted neutral of its temperature, like Radix’s natural pairing: blues on slate, greens on sage or olive, yellows and oranges on stone, reds and purples on mauve. The theme’s own primary family is among them: the first swatch keeps the theme’s own neutral, its family swatch pairs like the others. Pass your own list to replace them, or [] for none. The swatches wrap into evened rows of at most eight.

Surfaces

The Surfaces row picks the neutral every surface is built from. Matching (the default, dashed) keeps the selected palette’s own surfaces, so a default palette sits on its paired neutral. Picking Gray, Slate, Stone, Sage, Olive or Mauve puts any palette on that neutral instead, the theme’s own colors included. Text is re-solved against the new surfaces, so contrast holds. The choice is stored as surface in the value ("matching" or a neutral family’s id).

Mode

Like Theme Mode Select, only Light and Dark are offered. The checked option is the mode on screen, and picking the one the OS already prefers releases the override back to following the OS.

Density

compact, default and relaxed, applied as data-density on the page (absent for default). Density is about spacing only: gaps, insets and the padding inside controls tighten or loosen, but a control’s height and type stay those of its size. See Spacing for what it changes.

Corners

sharp, default and round scale every radius token by 0.5×, 1× or 1.5× (none and full stay put), applied as data-radius on the page (absent for default). It is opt-in: add "radius" to sections.

<AppearancePanel sections={["mode", "palette", "surface", "density", "radius"]} />

Locking and sections

locked forces fields and hides their controls — for example, a tenant’s brand palette. A locked control is never shown disabled. sections picks which controls show, and in what order.

<AppearancePicker
  locked={{ palette: "brand" }}
  sections={["mode", "density"]}
/>

Head script

Render AppearanceScript in the document head so the saved mode, palette, density and corners paint before the page does. It replaces Theme Mode Select’s script.

<script>
  import AppearanceScript from "@pindoba/svelte-appearance/script";
</script>

<AppearanceScript />

In Astro, the script is @pindoba/astro-appearance/script. The blocks hydrate on their own; listen for appearance:valuechange (detail.value) on a block to follow its changes.

---
import AppearanceScript from "@pindoba/astro-appearance/script";
---

<head>
  <AppearanceScript />
</head>
  • Components
  • Blocks