lily-design-system-html-text-size-picker
v0.1.1
Published
Lily Design System - HTML custom element text size picker
Maintainers
Readme
<text-size-picker> (HTML helper)
A reusable, headless vanilla HTML/JS text-size picker, packaged as
the <text-size-picker> custom element. Renders an icon button that
opens a dropdown listbox (WAI-ARIA APG listbox pattern) of text-size
slugs and, on every change, sets data-text-size="{slug}" on a target
element (default document.documentElement), optionally persisting the
choice to localStorage.
It is structurally identical to its theme-picker and locale-picker
siblings — all three helpers in this catalog are the same shape.
The single source of truth is spec/index.md. This file is the comprehensive user guide.
Why this exists
Letting users resize text — beyond browser zoom — is a WCAG 2.2
commitment (1.4.4 Resize Text, 1.4.12 Text Spacing). This helper owns
the selection + persistence + DOM application of a text-size
preference; the consumer owns the typography itself via CSS keyed on
[data-text-size="{slug}"]:
: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%;
}The element is a direct port of the Svelte canonical
lily-design-system-svelte-text-size-picker. APIs and behaviour match;
only the framework idioms differ.
Install
// One side-effect import registers <text-size-picker> globally:
import "./lily-design-system-html-text-size-picker";
// Or grab the class, helpers, and types:
import {
TextSizePicker,
sizeName, // "x-large" → "X Large"
nextTextSizePickerId,
LATIN_CAPITAL_LETTER_A, // the default "A" glyph
type TextSizePickerProps,
type TextSizePickerChangeDetail,
} from "./lily-design-system-html-text-size-picker";The barrel guards registration with
customElements.get("text-size-picker") so re-imports and SSR contexts
don't throw.
Quick start
<script type="module" src="/dist/text-size-picker.js"></script>
<text-size-picker
label="Text size"
sizes="small,medium,large,x-large"
storage-key="lily-text-size"
></text-size-picker>
<p class="text-size-picker-status" aria-live="polite">Text size: Medium</p>When the user picks large, the element:
- sets
data-text-size="large"on<html>, - writes
"large"tolocalStorage["lily-text-size"], - dispatches
new CustomEvent("textsizechange", { detail: { size: "large" }, bubbles: true, composed: true }).
The element does not style anything — your CSS maps each slug to a
real font scale. The closed button shows only the "A" glyph, so the
status region above is the default pattern: it is the only thing that
tells a user which size is active. Wire textsizechange (or read
el.value) to keep it current:
const picker = document.querySelector("text-size-picker")!;
const status = document.querySelector(".text-size-picker-status")!;
picker.addEventListener("textsizechange", (e) => {
const { size } = (e as CustomEvent<{ size: string }>).detail;
status.textContent = `Text size: ${picker.labelFor(size)}`;
});Because the list is a plain flow element, give it positioning — the helper ships no CSS at all:
.text-size-picker {
position: relative;
}
.text-size-picker-list {
position: absolute;
z-index: 10;
}Default size
The default slug is "medium" whenever "medium" appears in your
sizes list. The full resolution order on first connectedCallback:
valueattribute (if non-empty)localStorage[storage-key](ifstorage-keyis set and readable)default-valueattribute"medium"(if present insizes)sizes[0]""— nothing is applied; the picker waits for user interaction
Attributes
The complete table is in spec/index.md §4.1. Highlights:
| Attribute | Type | Required | Notes |
| --------------- | ------------- | -------- | ----------------------------------------------- |
| label | string | yes | aria-label on the button and the list. |
| sizes | string (CSV) | yes | Available slugs. |
| value | string | no | Current slug. |
| default-value | string | no | Initial when nothing else applies. |
| storage-key | string | no | localStorage persistence. |
| name | string | no | Defaults to "text-size"; on the hidden input. |
| size-labels | string (JSON) | no | Per-slug label overrides. |
| class | string | no | Extra class on the root <div>. |
There is no detection attribute. Unlike theme-picker
(detect-from-system) and locale-picker (detect-from-navigator),
the platform exposes no preferred-text-size signal to detect.
Class hooks
| Class | Element |
| -------------------------- | ---------------------------------- |
| text-size-picker | root <div> |
| text-size-picker-button | the icon <button> |
| text-size-picker-icon | the <span> holding the "A" glyph |
| text-size-picker-list | the <ul role="listbox"> |
| text-size-picker-option | each <li role="option"> |
Style [data-active] (keyboard-highlighted) differently from
[aria-selected="true"] (chosen) — they mean different things.
JS properties
Every observed attribute mirrors a camelCase JS property:
const picker = document.querySelector("text-size-picker") as TextSizePicker;
picker.sizes = ["small", "medium", "large", "x-large"]; // CSV-encoded in attribute
picker.sizeLabels = { small: "Compact", large: "Comfortable" };
picker.target = document.querySelector("section.panel") as HTMLElement;el.target accepts HTMLElement | null and has no attribute form.
Events
| Event | Detail | Bubbles | Composed |
| ---------------- | ------------------ | ------- | -------- |
| textsizechange | { size: string } | yes | yes |
Labels
size-labels overrides the default rendering per slug; otherwise the
slug is title-cased per hyphen-word (x-large → X Large).
Persistence
Pass storage-key to persist the active slug to localStorage. On a
fresh mount the picker reads back the stored slug as part of the
initial-value resolution. Storage errors are silently swallowed.
Custom rendering
Subclass and override renderButtonContent() to replace the glyph.
It is the HTML-helper stand-in for the children snippet the Svelte /
React / Vue siblings take, and this.value, this.open, and
this.labelFor(...) are all readable inside it:
class MyTextSizePicker extends TextSizePicker {
renderButtonContent(): Node {
const span = document.createElement("span");
span.textContent = `A — ${this.labelFor(this.value)}`;
return span;
}
}
customElements.define("my-text-size-picker", MyTextSizePicker);The base class still builds the button and listbox, so the aria wiring and the whole keyboard contract keep working.
Accessibility
- The control is an icon button (
aria-haspopup="listbox",aria-expanded,aria-controls) opening a<ul role="listbox">.aria-label={label}names both. - The keyboard contract is implemented in JS, not inherited from the
platform:
ArrowDown/Enter/Spaceopen,ArrowUpopens on the last option, arrows clamp,Home/Endjump,PageUp/PageDownmove by ten (clamped),Enter/Spaceselect,Escapecloses unchanged,Tabmoves focus to the button first and then closes, so the browser's default Tab proceeds from the picker's position. Printable characters run a 500 ms typeahead: a repeated character cycles through its matches, a buffer of differing characters refines the match from the active option. - Focus sits on the
<ul>while open; the highlighted option is conveyed byaria-activedescendant. Style.text-size-picker-list:focus-visibleand.text-size-picker-option[data-active], or keyboard users get no feedback. - Directly supports WCAG 2.2 — 1.4.4 (Resize Text), 1.4.10 (Reflow), and 1.4.12 (Text Spacing).
- Three known tradeoffs (icon-only naming, a custom listbox being
weaker than a native
<select>, font-dependent glyph rendering) are documented honestly in docs/accessibility.md.
SSR and static-site generation
The element compiles cleanly under static-site generators. On the
server no lifecycle hook runs; the SSG emits the literal
<text-size-picker> tag, and the browser upgrades it after the JS
loads. For zero-flicker first paint, pre-render <html
data-text-size="…"> and the matching <text-size-picker value="…">
host.
Testing
pnpm testExercises every numbered acceptance criterion in spec/index.md §7.
Files in this directory
| File | Purpose |
| --------------------------- | ------------------------------------------------ |
| spec/index.md | Single source of truth. |
| AGENTS.md | Fast-index pointer; loads the AGENTS bundle. |
| CLAUDE.md | @AGENTS.md. |
| text-size-picker.ts | The custom-element class. |
| text-size-picker.test.ts | vitest suite. |
| index.ts | Barrel + side-effectful customElements.define. |
| index.md | This file. |
| docs/accessibility.md | Roles, keyboard contract, known tradeoffs. |
License
MIT or Apache-2.0 or GPL-2.0 or GPL-3.0 or BSD-3-Clause. Contact [email protected] for other terms.
Lily™ and Lily Design System™ are trademarks.
