@phcdevworks/spectre-components is the Layer 3 Lit-based web component
package of the Spectre design system. It turns Spectre tokens
(@phcdevworks/spectre-tokens) and Spectre UI styling contracts
(@phcdevworks/spectre-ui) into reusable, accessible, framework-agnostic
custom elements — the canonical component implementation layer for Spectre,
designed to be consumed directly or wrapped by downstream adapter packages.
Contributing | Changelog | Security Policy
@phcdevworks/spectre-ui owns CSS: class recipes, Tailwind helpers, and the
styling contract that maps Spectre tokens to visual output. It ships CSS rules
and JavaScript class-name helpers — nothing more.
This package sits above that. It owns behavior: the Lit element classes that apply those CSS recipes, forward ARIA attributes to native elements, manage focus delegation, handle content projection, validate properties, and expose a stable TypeScript API surface for downstream adapters.
The separation keeps each layer focused:
| Layer | Package | Owns |
|---|---|---|
| L1 | @phcdevworks/spectre-tokens |
Design values and semantic meaning |
| L2 | @phcdevworks/spectre-ui |
CSS recipes and styling contracts |
| L3 | @phcdevworks/spectre-components |
Lit web component behavior and API |
| L4 | Downstream adapters | Framework-specific delivery |
If you only need CSS class names, use @phcdevworks/spectre-ui directly. If
you need ready-to-use HTML elements with behavior, accessibility, and a typed
API, use this package.
- Lit-based custom elements on the Custom Elements standard
- Renders in light DOM so
@phcdevworks/spectre-uiglobal styles apply directly — no Shadow DOM piercing required - ARIA attributes (
aria-label,aria-labelledby,aria-describedby) are forwarded to the native element, not left on the host - Focus and blur delegate to the inner native element
- Property validation with safe fallbacks in
willUpdate() - Idempotent
defineSpectre*()helpers — safe to call multiple times - ESM + CJS dual build with TypeScript declaration files
- Tree-shakeable subpath exports per component
- You are building UI with the Spectre design system and want standards-based custom elements with baked-in behavior and accessibility.
- You want typed form controls (
sp-button,sp-input,sp-select, etc.) that work in any framework or in plain HTML. - You are writing a framework adapter (React, Vue, Astro) and need a reliable, stable element layer to wrap.
- You only need CSS class names — use
@phcdevworks/spectre-uidirectly. - You are adding routing, shell logic, or app-startup orchestration — those are out of scope here.
- You need framework-specific component files (JSX, SFCs, Astro components) — those belong in a downstream adapter package.
npm install @phcdevworks/spectre-components @phcdevworks/spectre-ui @phcdevworks/spectre-tokensImport the CSS layers and register all components from a script tag or entry module. These are standard custom elements — no build step required for consumption.
<!doctype html>
<html lang="en">
<head>
<!-- Spectre CSS layers must load before any markup is rendered -->
<link rel="stylesheet" href="/node_modules/@phcdevworks/spectre-tokens/index.css" />
<link rel="stylesheet" href="/node_modules/@phcdevworks/spectre-ui/index.css" />
</head>
<body>
<sp-label for="email">Email address</sp-label>
<sp-input id="email" name="email" type="email" placeholder="you@example.com"></sp-input>
<sp-button variant="primary" type="submit">Send</sp-button>
<sp-button variant="ghost" type="button">Cancel</sp-button>
<script type="module">
import { defineSpectreComponents } from '/node_modules/@phcdevworks/spectre-components/dist/index.js';
defineSpectreComponents();
</script>
</body>
</html>import '@phcdevworks/spectre-tokens/index.css';
import '@phcdevworks/spectre-ui/index.css';
// Register everything at once
import { defineSpectreComponents } from '@phcdevworks/spectre-components';
defineSpectreComponents();
// Or register only what you use
import { defineSpectreButton } from '@phcdevworks/spectre-components/button';
import { defineSpectreInput } from '@phcdevworks/spectre-components/input';
defineSpectreButton();
defineSpectreInput();<sp-fieldset legend="Contact preferences">
<sp-label for="email">Email address</sp-label>
<sp-input id="email" name="email" type="email" required></sp-input>
<sp-label for="bio">Bio</sp-label>
<sp-textarea id="bio" name="bio" rows="4" maxlength="500"></sp-textarea>
<sp-label for="role">Role</sp-label>
<sp-select id="role" name="role">
<option value="admin">Admin</option>
<option value="user">User</option>
</sp-select>
<sp-checkbox name="terms" value="accepted" required>
I accept the <a href="/terms">terms of service</a>
</sp-checkbox>
<sp-radio name="plan" value="monthly">Monthly billing</sp-radio>
<sp-radio name="plan" value="annual">Annual billing</sp-radio>
<sp-button variant="primary" type="submit">Save</sp-button>
<sp-button variant="ghost" type="reset">Reset</sp-button>
</sp-fieldset>These are standard HTML custom elements. They work in every major framework that supports the Custom Elements standard:
React 19+ — supports custom element properties and events natively:
// React 19: properties and events work directly
<sp-input name="email" type="email" onInput={(e) => setValue(e.target.value)} />React 18 and below — set attributes via ref for properties, listen for
native events on the element:
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) inputRef.current.invalid = true;
}, []);
<sp-input ref={inputRef} name="email" />Vue 3 — supports custom elements out of the box with v-bind and
v-on directive compatibility. Mark the sp-* prefix in compilerOptions
as a custom element to suppress unknown-element warnings:
// vite.config.ts
plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('sp-') } } })]<sp-input name="email" :invalid="hasError" @change="handleChange" />Astro — use components as static custom elements or with client:load
when JavaScript interactivity is needed:
---
import '@phcdevworks/spectre-tokens/index.css';
import '@phcdevworks/spectre-ui/index.css';
---
<script>
import { defineSpectreComponents } from '@phcdevworks/spectre-components';
defineSpectreComponents();
</script>
<sp-button variant="primary">Click me</sp-button>Framework adapter packages that wrap these components into idiomatic JSX or SFC APIs belong in a downstream adapter — not in this package.
All components follow WCAG 2.1 AA baseline expectations by default.
ARIA attribute forwarding — aria-label, aria-labelledby, and
aria-describedby set on the host element are automatically forwarded to the
inner native element so screen readers receive them on the correct target.
Native element semantics — every component renders a real native element
(<button>, <input>, <textarea>, <select>, <label>, <fieldset>)
so browser accessibility APIs work without customization.
State communication
| State | ARIA effect |
|---|---|
loading |
aria-busy="true" on the native element |
invalid |
aria-invalid="true" on the native element |
disabled |
native disabled attribute (removes from tab order) |
required |
native required attribute |
Focus delegation — .focus() and .blur() called on the host are
delegated to the inner native element so external focus() calls work as
expected.
Label association — use <sp-label for="id"> paired with id on the
target control, or wrap controls inside a <sp-fieldset>. The for attribute
forwards to the native <label> element.
Keyboard behavior — provided entirely by the native element inside each component. No custom keyboard handling is layered on top.
All components render in light DOM (createRenderRoot() { return this; }).
This is intentional: it allows @phcdevworks/spectre-ui global CSS to reach
the native element directly without Shadow DOM piercing.
As a result, these components have no ::part() exports — the native element
is directly selectable using standard CSS combinators or the stable internal
data attributes:
/* Target the native input inside sp-input */
sp-input input { font-size: 0.875rem; }
/* Stable internal hook — won't break if markup restructures */
sp-input [data-sp-input-native] { font-size: 0.875rem; }Do not switch any component from light DOM to Shadow DOM without a design-system-level decision.
Renders a <button> with Spectre variant, size, loading, and pill support.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
variant |
primary | secondary | ghost | danger | success | cta | accent |
primary |
Visual style |
size |
sm | md | lg |
md |
Control size |
type |
button | submit | reset |
button |
Native button type |
label |
string | — | Text label (overridden by content projection) |
loading |
boolean | false |
Busy state — disables the button and shows loading label |
loading-label |
string | Loading |
Accessible text shown during loading |
disabled |
boolean | false |
Disables the button |
full-width |
boolean | false |
Spans full container width |
pill |
boolean | false |
Pill / fully-rounded corners |
name |
string | — | Form field name |
value |
string | '' |
Submitted value |
form |
string | — | Associates with a form by ID |
autofocus |
boolean | false |
Autofocus on page load |
id |
string | — | Forwarded to the native <button> |
title |
string | — | Forwarded to the native <button> |
aria-label |
string | — | Forwarded to the native <button> |
aria-labelledby |
string | — | Forwarded to the native <button> |
aria-describedby |
string | — | Forwarded to the native <button> |
Events — native button events bubble normally (click, focus, blur).
Content projection — place children inside <sp-button> to use them as
button content instead of the label property:
<sp-button variant="primary">
<svg aria-hidden="true">...</svg>
Save changes
</sp-button>Internal target — [data-sp-button-native] selects the native <button>.
Renders an <input> with state, size, and type support.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
type |
text | email | password | search | tel | url | number | date | datetime-local | month | time | week |
text |
Native input type |
size |
sm | md | lg |
md |
Control size |
name |
string | — | Form field name |
value |
string | '' |
Current value |
placeholder |
string | — | Placeholder text |
disabled |
boolean | false |
Disables the input |
loading |
boolean | false |
Busy state |
readonly |
boolean | false |
Read-only mode |
required |
boolean | false |
Marks field as required |
invalid |
boolean | false |
Error state (aria-invalid) |
success |
boolean | false |
Success state |
full-width |
boolean | false |
Spans full container width |
pill |
boolean | false |
Pill / fully-rounded corners |
autocomplete |
string | — | Native autocomplete hint |
inputmode |
string | — | Virtual keyboard hint |
min / max / step |
string | — | Numeric/date range |
minlength / maxlength |
number | — | Character length constraints |
form |
string | — | Associates with a form by ID |
autofocus |
boolean | false |
Autofocus on page load |
id / title / aria-* |
string | — | Forwarded to native <input> |
Events — input and change fire from the native <input> and bubble.
Internal target — [data-sp-input-native] selects the native <input>.
Renders a <textarea> with row control and resize support.
Attributes — same as sp-input except no type, min, max, step, and adds:
| Attribute | Type | Default | Description |
|---|---|---|---|
rows |
number | 2 |
Visible row height |
Events — input and change fire from the native <textarea>.
Internal target — [data-sp-textarea-native] selects the native <textarea>.
Renders a <select>. Pass <option> elements as children — they are
projected into the native select element.
Attributes — same as sp-input minus type, placeholder, readonly,
inputmode, min, max, step, minlength, maxlength.
Events — input and change fire from the native <select>.
Content projection — <option> and <optgroup> children are moved into
the native <select>:
<sp-select name="country" required>
<option value="">Select a country</option>
<optgroup label="Americas">
<option value="us">United States</option>
<option value="ca">Canada</option>
</optgroup>
</sp-select>Internal target — [data-sp-select-native] selects the native <select>.
Renders a <label> wrapping an <input type="checkbox"> with indicator.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
name |
string | — | Form field name |
value |
string | on |
Submitted value when checked |
checked |
boolean | false |
Checked state |
label |
string | — | Text label (overridden by content projection) |
disabled |
boolean | false |
Disables the checkbox |
loading |
boolean | false |
Busy state |
required |
boolean | false |
Marks field as required |
invalid |
boolean | false |
Error state |
success |
boolean | false |
Success state |
form / autofocus / id / title / aria-* |
— | — | Forwarded to native <input> |
Events — input and change fire from the native checkbox input.
Content projection — children become the label content (supports rich markup):
<sp-checkbox name="terms" value="accepted" required>
I accept the <a href="/terms">terms of service</a>
</sp-checkbox>Internal target — [data-sp-checkbox-native] selects the native checkbox.
Renders a <label> wrapping an <input type="radio"> with indicator.
Group multiple sp-radio elements by giving them the same name.
Attributes — same as sp-checkbox. value defaults to on.
Events — input and change fire from the native radio input.
Content projection — same as sp-checkbox.
<sp-radio name="plan" value="monthly">Monthly — $9/mo</sp-radio>
<sp-radio name="plan" value="annual">Annual — $90/yr</sp-radio>Internal target — [data-sp-radio-native] selects the native radio input.
Renders a <label> with for forwarding. Use to associate a visible label
with any form control.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
for |
string | — | ID of the associated control (forwarded to native <label>) |
id / title / aria-* |
string | — | Forwarded to native <label> |
Content projection — children become the label text (supports rich markup):
<sp-label for="email">
Email address <span aria-hidden="true">*</span>
</sp-label>Internal target — [data-sp-label-native] selects the native <label>.
Renders a <fieldset> with optional legend and group-level state.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
legend |
string | — | Text for the <legend> element |
disabled |
boolean | false |
Disables all controls in the group |
loading |
boolean | false |
Busy state |
invalid |
boolean | false |
Group-level error state |
success |
boolean | false |
Group-level success state |
form / name / id / title / aria-* |
string | — | Forwarded to native <fieldset> |
Content projection — children are placed inside the native <fieldset>
alongside the legend:
<sp-fieldset legend="Billing address" name="billing">
<sp-label for="city">City</sp-label>
<sp-input id="city" name="city" required></sp-input>
<sp-label for="zip">ZIP code</sp-label>
<sp-input id="zip" name="zip" type="text" maxlength="10"></sp-input>
</sp-fieldset>Internal target — [data-sp-fieldset-native] selects the native <fieldset>.
Exports everything from all component entry points plus the bulk registration helper.
Bulk registration
import { defineSpectreComponents } from '@phcdevworks/spectre-components';
defineSpectreComponents(); // registers all sp-* elementsPer-component helpers (same as individual entry points):
defineSpectreButton, defineSpectreInput, defineSpectreTextarea,
defineSpectreSelect, defineSpectreCheckbox, defineSpectreRadio,
defineSpectreLabel, defineSpectreFieldset
Element classes:
SpectreButtonElement, SpectreInputElement, SpectreTextareaElement,
SpectreSelectElement, SpectreCheckboxElement, SpectreRadioElement,
SpectreLabelElement, SpectreFieldsetElement
Button constants and types:
spectreButtonVariants, spectreButtonSizes, spectreButtonTypes,
SpectreButtonVariant, SpectreButtonSize, SpectreButtonType,
SpectreButtonProps
Input / textarea / select constants and types:
spectreInputSizes, spectreInputTypes, SpectreInputSize,
SpectreInputType, SpectreInputProps, SpectreTextareaProps,
SpectreSelectProps
Props interfaces (checkbox / radio / label / fieldset):
SpectreCheckboxProps, SpectreRadioProps, SpectreLabelProps,
SpectreFieldsetProps
Each entry point registers only that component and exports only its surface:
| Entry point | Registers | Key exports |
|---|---|---|
.../button |
sp-button |
defineSpectreButton, SpectreButtonElement, button constants and types |
.../input |
sp-input |
defineSpectreInput, SpectreInputElement, input constants and types |
.../textarea |
sp-textarea |
defineSpectreTextarea, SpectreTextareaElement, SpectreTextareaProps |
.../select |
sp-select |
defineSpectreSelect, SpectreSelectElement, SpectreSelectProps |
.../checkbox |
sp-checkbox |
defineSpectreCheckbox, SpectreCheckboxElement, SpectreCheckboxProps |
.../radio |
sp-radio |
defineSpectreRadio, SpectreRadioElement, SpectreRadioProps |
.../label |
sp-label |
defineSpectreLabel, SpectreLabelElement, SpectreLabelProps |
.../fieldset |
sp-fieldset |
defineSpectreFieldset, SpectreFieldsetElement, SpectreFieldsetProps |
Size constants are shared between input, textarea, and select. Import
spectreInputSizes / SpectreInputSize from .../input when needed
alongside textarea or select.
spectre-tokens → design values (colors, spacing, typography)
spectre-ui → CSS recipes and Tailwind helpers
spectre-components → Lit web component behavior ← you are here
[adapters] → React / Vue / Astro wrappers
The Golden Rule: tokens define meaning, UI defines structure, components define behavior, adapters define delivery. This package only owns the behavior layer.
git clone https://github.com/phcdevworks/spectre-components.git
cd spectre-components
npm install
npm run check # lint + typecheck + test + build + export validationRequires Node.js ^22.12.0 || >=24.0.0 and npm 11.14.1.
| Command | Purpose |
|---|---|
npm run check |
Full validation (lint → typecheck → test → build → export check) |
npm run build |
Compile ESM + CJS with declarations into dist/ |
npm test |
Run Vitest suite under happy-dom |
npm run lint |
ESLint |
npm run check:exports |
Verify built subpath exports resolve correctly |
npm run dev |
tsup watch mode |
npm run clean |
Remove dist/ and coverage/ |
Key source areas:
src/components/— one directory per custom elementsrc/utils/—base.ts,projectable.ts,form.ts,dom.tssrc/index.ts— root public API and bulk registration helpertests/— component behavior coverage (Vitest + happy-dom)scripts/check-exports.js— post-build export resolution check
Build fails with type errors — TypeScript 6 is required. Run
npm install, then npm run build.
Tests fail in CI but pass locally — Tests run under happy-dom. Confirm you
are on Node ^22.12.0 || >=24.0.0. CI tests both versions.
Custom element already defined — Each defineSpectre*() helper is
idempotent; calling it twice is safe. If you see conflicts, two different
versions of this package may be loaded in the same page.
Styles are not applying — The Spectre CSS layers must load before
components are registered. Import @phcdevworks/spectre-tokens/index.css and
@phcdevworks/spectre-ui/index.css at the top of your entry module.
Properties not reflecting in React 18 — React 18 sets custom element
properties as attributes. Use a ref to set properties imperatively, or
upgrade to React 19 which supports custom elements fully.
Run the full validation gate before any pull request:
npm run checkThis runs: lint → typecheck → tests → build → export validation. All steps must pass.
Claude Code (claude-sonnet-4-6) is the primary development agent for this
repository. Codex handles releases and production stabilization. Jules handles
small automated fixes and dependency updates. GitHub Copilot provides
development support.
Claude Code, Codex, and Copilot do not create git commits by default. Jules may
commit only bounded automated maintenance when the JULES.md scope and
validation gates pass. Release decisions, tags, and publishing remain with
Bradley Potts.
Protected from automated change: component public API surface (tags, properties, events, slots, ARIA), the light-DOM rendering model, and the zero-hardcode-values rule. See AGENTS.md for full agent governance and boundary rules.
PHCDevworks maintains this package as part of the Spectre suite.
Contribution boundaries:
- Components must consume
@phcdevworks/spectre-uiclass helpers — do not recreate CSS locally. - Design values must come from
@phcdevworks/spectre-tokens— do not hardcode colors, spacing, or other visual primitives. - Component tags, properties, events, slots, and ARIA behavior are stable API — breaking changes require a semver major bump.
- Render in light DOM only — Shadow DOM changes require design-system approval.
- No framework-specific code — no JSX, SFCs, or Astro components in this package.
- Run
npm run checkbefore opening a pull request.
See CONTRIBUTING.md for the full guide.
MIT © PHCDevworks. See LICENSE.