Design System Links
Your design system almost certainly ships a link component — <sp-link>, <md-*>, Shoelace's <sl-...>, or an in-house one. It is a custom element that declares its own href property and renders an anchor inside its shadow root. It is a link in every way that matters to a reader, and in no way that matters to document.querySelector('a').
uiSref drives it, but the href needs one option: assignHref: true.
html`<sp-link ${uiSref('components', {}, { assignHref: true })}
>Components</sp-link
>`;Why 'auto' refuses it
assignHref decides where the generated href is written:
| value | behaviour |
|---|---|
true | write it to whatever element carries the directive — the 1.x default |
'auto' | write it only where HTML defines an href: <a>, <area>, SVG <a> |
false | never write it; the app manages the attribute itself |
'auto' tests the element's tag name, not its shape. A design-system link's localName is sp-link, not a, so 'auto' skips it — deliberately. The rule it enforces is "no inert href on elements that cannot use one", and it has no way to tell a <sp-link href> that means it from a <div> that does not. true is the escape hatch for the elements that do mean it, which is why it survives the 2.0 default flip.
The example above stages all three cases side by side and prints each element's live href attribute:
<sp-link>withassignHref: true— carrieshref="#/components"<sp-link>withassignHref: 'auto'— nohrefattribute at all<a>withassignHref: 'auto'— carrieshref="#/tokens", because'auto'writes to real anchors
All three navigate on click. assignHref governs the attribute only; the click handler is never affected by it.
What the href buys you
The href is not decoration on a link-shaped custom element. It is what makes the component behave like the link it renders:
- the browser shows the target URL in the status bar on hover
- middle-click and Cmd/Ctrl-click open a new tab
- "Copy link address" produces a working URL
- assistive technology announces a link, and
uiSrefActivecan mark it witharia-current
Most design-system link components forward href to their internal anchor, so writing the attribute on the host is enough for all of it.
Choosing the option
| your element | option |
|---|---|
<a> / <area> | nothing — 'auto' already writes it |
a custom element with its own href | assignHref: true |
<button>, <tr>, <div> — no href at all | assignHref: 'auto' |
an element whose href you set yourself | assignHref: false |
For a control whose state cannot be encoded in a URL — a sref carrying non-URL parameters — no href beats a wrong one, whatever the element is. See Unmatched URLs for the related case of routing a URL that matches no state.
Under lit's development build
assignHref: true on a non-anchor logs a one-time console warning naming 'auto' as the fix, because it cannot know your element forwards href. The warning is gated to lit's dev build; production builds are silent. Pass false and set the property yourself if you would rather not see it.
Setting up the example
The example is a standalone Vite app that installs Spectrum Web Components from npm:
npm install @spectrum-web-components/link @spectrum-web-components/themeimport '@spectrum-web-components/theme/sp-theme.js';
import '@spectrum-web-components/theme/scale-medium.js';
import '@spectrum-web-components/link/sp-link.js';Spectrum's components need a theme ancestor, so the router lives inside one. sp-theme takes a literal color stop — lightest, light, dark, or darkest, with no auto — so following the reader's OS preference is the app's job.
Each stop is a separate theme fragment, and importing it is what registers it with sp-theme. Import both statically and every reader downloads a stop they will never see; load them on demand and only the one in use ships, with the other arriving on its own chunk if the preference ever flips. A reactive controller owns both halves — the media query and the fragment:
import type { ReactiveController, ReactiveControllerHost } from 'lit';
const themeFragments = {
light: () => import('@spectrum-web-components/theme/theme-light.js'),
dark: () => import('@spectrum-web-components/theme/theme-dark.js'),
} satisfies Record<string, () => Promise<unknown>>;
export type ThemeColor = keyof typeof themeFragments;
export class ColorSchemeController implements ReactiveController {
readonly #host: ReactiveControllerHost;
readonly #query = window.matchMedia('(prefers-color-scheme: dark)');
readonly #loaded = new Set<ThemeColor>();
#applied?: ThemeColor;
constructor(host: ReactiveControllerHost) {
this.#host = host;
host.addController(this);
}
/** the preferred stop, once its fragment is registered */
get color(): ThemeColor | undefined {
return this.#applied;
}
get #preferred(): ThemeColor {
return this.#query.matches ? 'dark' : 'light';
}
hostConnected(): void {
this.#query.addEventListener('change', this.#onChange);
void this.#adopt();
}
hostDisconnected(): void {
this.#query.removeEventListener('change', this.#onChange);
}
readonly #onChange = () => void this.#adopt();
async #adopt(): Promise<void> {
const wanted = this.#preferred;
if (!this.#loaded.has(wanted)) {
try {
await themeFragments[wanted]();
this.#loaded.add(wanted);
} catch (error) {
// a fragment that never arrives must not blank the app: keep the stop
// already on screen, or adopt this one unthemed if there is none yet
console.error(`could not load the ${wanted} theme fragment`, error);
this.#applied ??= wanted;
this.#host.requestUpdate();
return;
}
}
// re-read the query: whatever it says now is what should be on screen
const preferred = this.#preferred;
if (this.#loaded.has(preferred)) this.#applied = preferred;
this.#host.requestUpdate();
}
}A fragment can also fail to arrive — a stale chunk after a deploy, a flaky network — and an example that renders nothing until one loads would go blank. The catch keeps the stop already on screen, or adopts the wanted one unthemed when there is nothing to fall back to: unstyled chrome beats a blank page, and a later flip retries the import.
The re-read is what keeps this honest. A fragment that finishes loading applies the preference as it stands then, not the one it was asked for, so overlapping flips converge on the current scheme without anyone tracking which load started last — and the stop already on screen stays put while an unloaded one is still in flight.
The host is an ordinary element that holds the router and waits for a registered stop, mounted straight from the HTML:
@customElement('app-shell')
export class AppShell extends LitElement {
private readonly scheme = new ColorSchemeController(this);
private readonly router = createRouter();
render() {
const { color } = this.scheme;
// hold the first paint until a stop is registered — sp-theme adopts the
// one it is told to, and an unregistered stop leaves it nothing to adopt
if (!color) return nothing;
return html`
<sp-theme system="spectrum" color=${color} scale="medium">
<ui-router .uiRouter=${this.router}>
<app-root></app-root>
</ui-router>
</sp-theme>
`;
}
}Chrome around the components reads its colors from Spectrum's own tokens — var(--spectrum-gray-800) for text, var(--spectrum-gray-300) for rules — which inherit through the shadow boundary and re-resolve with the theme.
Reading the preference instead of exposing a theme control of its own buys one thing worth knowing about: the live example above follows this page's appearance toggle, not just your OS setting. A page that sets color-scheme propagates it to a same-origin <iframe>, so the embedded document's prefers-color-scheme flips with the docs theme and the controller re-renders on the spot — the dark fragment arriving on the first flip. An app with its own bespoke toggle would have needed the embedding page to plumb the scheme in.
Nothing about the pairing is Spectrum-specific: any link-shaped custom element takes the same option.