Custom Display widgets
The Display controller is headless. mountDisplayWidget and DisplayWidget are optional views over that controller; your application can supply its entire interface. No bundled widget CSS is required.
These buttons share a controller with the standard select above. Change either one to see both update.
Reuse createDisplay(config) and its setProfile, getSnapshot, subscribe and start methods. Your custom control supplies markup and interaction; the controller still owns validation, CSS tokens, persistence, reset behavior and synchronization. A custom widget and a standard widget can share the same controller without an adapter.
Vanilla custom control
This example creates a group of native toggle buttons. It reads labels from configuration, updates the pressed state without replacing focused elements, and releases its lease and subscription on destruction.
// A headless vanilla widget: the controller handles all preference behavior.
function mountProfileButtons(container, display) {
const doc = container.ownerDocument;
const group = doc.createElement("fieldset");
group.className = "custom-display";
const legend = doc.createElement("legend");
legend.textContent = display.config.controlLabel;
const choices = doc.createElement("div");
choices.className = "custom-display-choices";
const description = doc.createElement("p");
const buttons = display.config.profiles.map((profile) => {
const button = doc.createElement("button");
button.type = "button";
button.textContent = profile.label;
button.dataset.profile = profile.id;
if (profile.description)
button.setAttribute("aria-description", profile.description);
choices.append(button);
return button;
});
const sync = () => {
const current = display.getSnapshot();
buttons.forEach((button) =>
button.setAttribute(
"aria-pressed",
String(button.dataset.profile === current),
),
);
const profile = display.config.profiles.find(
(profile) => profile.id === current,
);
description.textContent = `${profile.label}${profile.description ? ` — ${profile.description}` : ""}`;
};
const change = (event) => {
const button = event.target.closest?.("button[data-profile]");
if (button && choices.contains(button))
display.setProfile(button.dataset.profile);
};
const stop = display.start({ window: doc.defaultView });
const unsubscribe = display.subscribe(sync);
choices.addEventListener("click", change);
group.append(legend, choices, description);
container.append(group);
sync();
return {
destroy() {
choices.removeEventListener("click", change);
unsubscribe();
group.remove();
stop();
},
};
}
Mount it after the container exists, using a controller shared with your app:
import { createDisplay } from "openregard/display";
const display = createDisplay(config);
const widget = mountProfileButtons(container, display);
// Later, when this UI is removed:
widget.destroy();When reusing a controller initialized before paint, pass that existing instance instead of creating another one. A page-level display.start() lease can retain preferences while all controls are temporarily unmounted.
React custom control
Reuse useDisplayProfile(display) for the subscription, SSR snapshot and browser lease. The returned ID is reactive; use the same controller to change the selection. You do not need to render DisplayWidget or reimplement localStorage and storage events.
import { useDisplayProfile } from "openregard/display/react";
import type { DisplayController } from "openregard/display";
export function ProfileButtons({ display }: { display: DisplayController }) {
// Reuses the library's subscription, SSR snapshot and lifecycle handling.
const current = useDisplayProfile(display);
const selected = display.config.profiles.find((p) => p.id === current)!;
return (
<fieldset className="custom-display">
<legend>{display.config.controlLabel}</legend>
<div className="custom-display-choices">
{display.config.profiles.map((profile) => (
<button
key={profile.id}
type="button"
aria-pressed={profile.id === current}
aria-description={profile.description}
onClick={() => display.setProfile(profile.id)}
>
{profile.label}
</button>
))}
</div>
<p>
{selected.label}
{selected.description ? ` — ${selected.description}` : ""}
</p>
</fieldset>
);
}
// <ProfileButtons display={display} />
// Reuse the same createDisplay(config) instance as the rest of your app.
Pass a stable controller created once per browser app or SSR request. If your app also uses a provider, give both the provider and custom controls that instance: <DisplayProvider display={display}>. The hook works independently of the standard widget and provider.
Style and interaction
Your custom UI owns its styling. The live button example uses this CSS; it does not import or depend on openregard/display/style.css:
.custom-display {
min-inline-size: 0;
margin: 0;
padding: 1.25rem;
border: 1px solid var(--regard-border-color, currentColor);
border-radius: 0.5rem;
color: inherit;
}
.custom-display legend {
padding-inline: 0.4em;
font-weight: 600;
}
.custom-display-choices {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.custom-display button {
font: inherit;
padding: 0.65em 0.9em;
min-block-size: 2.75em;
max-inline-size: 100%;
overflow-wrap: anywhere;
border: 1px solid currentColor;
border-radius: 0.35rem;
color: var(--ink, CanvasText);
background: var(--paper, Canvas);
cursor: pointer;
}
.custom-display button[aria-pressed="true"] {
color: var(--paper, Canvas);
background: var(--ink, CanvasText);
text-decoration: underline;
text-underline-offset: 0.25em;
}
.custom-display button:focus-visible {
outline: 3px solid currentColor;
outline-offset: 4px;
}
.custom-display p {
margin-block: 1rem 0;
font-size: 0.85em;
}
@media (forced-colors: active) {
.custom-display button {
color: ButtonText;
background: ButtonFace;
}
.custom-display button[aria-pressed="true"] {
border: 3px double ButtonText;
color: ButtonText;
background: ButtonFace;
}
}
Keep a visible group label and full choice names. Native buttons already support Tab, Enter and Space; aria-pressed exposes their state. A radio-group design instead needs radio semantics and its expected keyboard behavior. Preserve focus on updates, indicate selection with more than color, allow labels to wrap and check forced colors, narrow layouts and enlargement. Tokens cannot supply these semantics for custom markup.
The live example shares its controller with the standard select above it. Switching either updates both, and selecting a profile still updates the page and other same-origin tabs.