Preference helpers

Seven small packages per framework, each owning one user job end to end, plus a bar that assembles five of them. You've met theme-picker; here are locale-picker, text-size-picker and motion-picker (preferences), share-picker and search-picker (actions), date-time-picker (a form value), and picker-bar.

What you'll build

A settings panel where users pick their language and text size — choices that apply to the whole document, persist across visits, and cost you two components and a few lines of CSS. About 15 minutes.

Before you start

Finish the framework tutorial for your stack. The samples below are Svelte; the React, Vue, Angular, HTML, Nunjucks, and Blazor helpers match contract-for-contract.

The shape they share

  • A headless icon button that opens a WAI-ARIA APG listbox (or, for date-time-picker, a date-picker dialog) — no native <select>, so every framework gets identical keyboard behaviour and styling hooks.
  • One class hook per package (.locale-picker, .text-size-picker, .share-picker, .date-time-picker), zero CSS shipped.
  • SSR-safe: DOM writes happen only in the mount/effect lifecycle.
  • i18n-clean: every user-facing string is a prop.
  • Available for Svelte (canonical), React, Vue, Angular, HTML custom element, Nunjucks, and Blazor — same contract in each.

theme-picker, locale-picker, and text-size-picker each own one user preference end to end (selection, DOM application, optional localStorage persistence via a storageKey prop). share-picker and date-time-picker are different in kind: share-picker owns an action — it applies nothing to the document and persists nothing — and date-time-picker owns a form value, since a date in a form is data rather than a preference.

locale-picker — language and direction

Lets the user pick a locale, then writes lang and dir to the document root so your i18n library, your CSS, and assistive technology all follow. RTL is detected automatically for Arabic, Hebrew, and other right-to-left scripts. It focuses purely on signalling — your i18n library handles the translation.

<script>
  import LocalePicker from "@lilydesignsystem/svelte-locale-picker";

  let locale = $state("en-US");
</script>

<LocalePicker
  label="Language"
  locales={["en-US", "fr", "ar", "he", "ja"]}
  bind:value={locale}
  storageKey="my-app-locale"
/>

Options are labelled from a built-in table of locale names (overridable via localeLabels), each option carries its own lang attribute so screen readers pronounce "Français" in French, and underscore codes like en_US round-trip losslessly to BCP 47 en-US. Wire onChange (or the bound value) into i18next, Paraglide, vue-i18n, or whatever you use.

text-size-picker — reader-controlled sizing

An accessibility win that takes minutes: the helper sets data-text-size="{slug}" on the document root and your CSS maps each value to sizing.

<script>
  import TextSizePicker from "@lilydesignsystem/svelte-text-size-picker";
</script>

<TextSizePicker
  label="Text size"
  sizes={["small", "medium", "large", "x-large"]}
  storageKey="my-app-text-size"
/>
:root[data-text-size="small"]   { font-size: 87.5%; }
:root[data-text-size="medium"]  { font-size: 100%; }
:root[data-text-size="large"]   { font-size: 112.5%; }
:root[data-text-size="x-large"] { font-size: 125%; }

Size your layout in rem and the whole app scales with the user's choice. Labels are title-cased from the slugs ("X Large") or overridden with sizeLabels.

Check your work: pick "Large" from the picker, and the page text grows. Reload — it's still large: the choice persisted to localStorage. Switch your language picker to Arabic and dir="rtl" lands on the document automatically.

share-picker — send this page

A single-glyph button that opens the device's native share sheet where one exists, and otherwise falls back to a list of destinations you supply, plus a copy-the-URL action. It ships no social-network URLs — which networks belong in your product is your call, not the design system's.

<script>
  import SharePicker from "@lilydesignsystem/svelte-share-picker";

  const targets = [
    { id: "mastodon", label: "Mastodon",
      href: (url, title) => `https://mastodon.social/share?text=${encodeURIComponent(title)}%20${encodeURIComponent(url)}` },
    { id: "email", label: "Email",
      href: (url, title) => `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`, newTab: false },
  ];
</script>

<SharePicker
  label="Share this page"
  title="An article worth reading"
  {targets}
  copyLabel="Copy link"
  copiedLabel="Link copied"
/>

url defaults to location.href, so the common case needs no wiring. Force a path with strategy="list" or strategy="native" when you need consistent behaviour across platforms for testing or screenshots.

date-time-picker — a date, a time, or both

A text field you can type into, plus an icon button that opens a WAI-ARIA APG date-picker dialog. Locale-correct by construction — month names, weekday names, first day of week, and 12- vs 24-hour clock all come from Intl — and the value is always a sortable, timezone-free ISO string: "2026-03-15" for mode="date", "09:30" for mode="time", "2026-03-15T09:30" for mode="datetime".

<script>
  import DateTimePicker from "@lilydesignsystem/svelte-date-time-picker";

  let appointment = $state("");
</script>

<DateTimePicker
  inputId="appointment"
  name="appointment"
  label="Choose an appointment date"
  locale="en-GB"
  bind:value={appointment}
  labels={{
    previousYear: "Previous year",
    previousMonth: "Previous month",
    nextMonth: "Next month",
    nextYear: "Next year",
    confirm: "OK",
    cancel: "Cancel",
  }}
/>

The ISO value is identical to what <input type="date"> posts, so you can swap the native control in or out without touching your backend.

motion-picker — respect reduced motion

The helper sets data-motion="{slug}" on the document root. Its first value comes from the visitor's operating-system prefers-reduced-motion setting — always, with no opt-in flag, because motion is a real accessibility need (WCAG 2.3.3). Your CSS and scripts decide what gets suppressed.

<script>
  import MotionPicker from "@lilydesignsystem/svelte-motion-picker";
</script>

<MotionPicker
  label="Motion"
  motions={["no-preference", "reduce"]}
  storageKey="my-app-motion"
/>
:root[data-motion="reduce"] *,
:root[data-motion="reduce"] *::before,
:root[data-motion="reduce"] *::after {
  animation: none !important;
  transition: none !important;
  scroll-behavior: auto !important;
}

search-picker — a search box that stays out of the way

An icon button that opens a search field. Pressing Return, or the ⏎ button beside the field, navigates with a plain GET request to /?<text> (the text trimmed and URI-encoded; an empty query goes nowhere). Its three accessible names are required, because the helper ships no English.

<script>
  import SearchPicker from "@lilydesignsystem/svelte-search-picker";
  import { goto } from "$app/navigation";
</script>

<SearchPicker
  label="Search this site"
  inputLabel="Search terms"
  submitLabel="Search"
  placeholder="Search…"
  action="/search"
  navigate={(href) => goto(href)}
/>

action changes the path (/search?foo), and navigate keeps the navigation in-app; without it the helper calls location.assign.

picker-bar — the whole header row at once

picker-bar renders search, theme, locale, text-size and share in one row, with the full set of reference themes and the seven-step text scale pre-wired. Every accessible name arrives in one required labels object.

<script>
  import PickerBar from "@lilydesignsystem/svelte-picker-bar";
</script>

<PickerBar
  labels={{
    search: "Search", searchInput: "Search terms", searchSubmit: "Search",
    theme: "Theme", locale: "Language", textSize: "Text size", share: "Share",
  }}
  themesUrl="/themes/"
  locales={["en", "fr", "es"]}
  shareTargets={targets}
/>

Reach any wrapped picker with searchProps, themeProps, localeProps, textSizeProps and shareProps.

Tooltips come built in

Every picker shows its label in a tooltip when the pointer rests on its button or the button has keyboard focus. The tooltip stays while the pointer moves onto it, Escape dismisses it wherever focus is, and it never shows while the picker's popup is open. It carries the same text as the button's accessible name, so there is nothing to configure and nothing to translate twice; the reference themes style it.

A settings panel in one place

The four preference helpers compose naturally:

<fieldset class="fieldset">
  <legend>Preferences</legend>
  <ThemePicker    label="Theme"     themesUrl="/themes/" themes={themes} storageKey="pref-theme" />
  <LocalePicker   label="Language"  locales={locales}    storageKey="pref-locale" />
  <TextSizePicker label="Text size" sizes={sizes}        storageKey="pref-text-size" />
  <MotionPicker   label="Motion"    motions={motions}    storageKey="pref-motion" />
</fieldset>

Verified: the locale-picker, text-size-picker, share-picker, date-time-picker and combined-panel snippets were compiled individually against the published 0.1.1 packages through svelte/server (2026-08-29); the motion-picker, search-picker and picker-bar snippets were rendered against the packages' own components on 2026-10-06.

Where to get them

# one directory per package in the monorepo, e.g.
git clone https://github.com/LilyDesignSystem/lily-design-system
cd lily-design-system/lily-design-system-svelte-theme-picker
# or lily-design-system-react-theme-picker, -vue- / -angular- / -html- / -web-components- / -nunjucks- / -blazor-

Each package has its own spec, tests mapped to the spec clauses, runnable examples (including SSR cookie patterns), and a build + publish pipeline. The Svelte packages are canonical; when in doubt, their contracts win.

Next steps

Questions along the way? The help page is full of answers — and if a step could be clearer, tell us and we'll gladly improve the tutorial.