Building Components
WARNING
This page is a work in progress and actively evolving. It's a draft proposal that still needs review and approval from other core team members — treat the specifics here as tentative rather than settled guidance.
Three layers
Before deciding between Lit, Vue, and vanilla JavaScript, it helps to think about which layer of the app you're working in. Open Library's frontend falls into three layers. Each has a default home, but the orchestration layer has room to pick different tools depending on complexity.
UI layer — the visual building blocks. Buttons, chips, popovers, composite widgets. Pure presentation: no
fetch, no app state beyond the component itself, no assumptions about where the data came from. Lit is the tool, and these components live inopenlibrary/components/lit/. Anything in this folder is expected to work identically across the whole site.Orchestration layer — the page's "app." Orchestration code is the glue that wires up a specific page or feature: it listens for events from UI components, coordinates page-level state, and calls services to talk to the backend. This is where the frontend's domain knowledge lives — who the current user is, what "add to list" means, which API to hit. The tool depends on complexity:
- Vanilla JavaScript controllers for most pages — a plain module under
openlibrary/plugins/openlibrary/js/<feature>/that wires up the DOM. - Lit components with domain logic when a complex interaction pattern benefits from component encapsulation (its own state, slots, events). These live outside
components/lit/— likely in a feature folder underplugins/openlibrary/js/, though the exact location and build path are still being figured out (see the callout below). They may import services and make network calls — the design-system contract doesn't apply to them. - Vue when the feature is genuinely stateful and multi-view — a mini-app.
- Vanilla JavaScript controllers for most pages — a plain module under
Service layer — the domain. API calls, response shaping, auth helpers, business rules. Plain ES modules, no framework.
ListService.jsis the existing example to follow.
The payoff of this split: a design-system Lit component is trivially swappable because nothing downstream depends on its internals, a service is trivially testable because it has no DOM, and the orchestration layer is the only place that's allowed to know about both.
Which layer am I in?
Ask yourself what you're building, and the layer usually picks itself:
| Thing you're building | Layer | Default tool |
|---|---|---|
| A reusable presentational widget (chip, pagination, button) | UI | Lit, in components/lit/ |
| Wiring a specific page's components to API calls and state | Orchestration | Vanilla controller |
A reusable interaction pattern that needs domain logic (fetch, app-specific state) | Orchestration | Lit, in a feature folder (location TBD) |
| A stateful, multi-view mini-app (barcode scanner, library explorer) | Orchestration | Vue |
| Calling the backend, transforming responses, encoding domain rules | Service | ES module |
| A purely visual or structural change, no client-side JS needed | — | Template change |
NOTE
Parts of this guide describe where we're heading, not just where the codebase is today.
- The design-system Lit folder (
openlibrary/components/lit/) is real, with components likeOlPaginationandOlPopoveralready in use. Bundled by Vite intool-components.js. - The orchestration-layer Lit pattern (Lit components with domain logic outside
components/lit/) is new — no examples exist in the codebase yet. The build pipeline hasn't been exercised on this either: design-system Lit goes through Vite (vite-lit.config.mjs, input hardcoded tocomponents/lit/index.js), while page JS goes through webpack (plugins/openlibrary/js/index.js→all.js). The two pipelines don't currently share a path for orchestration-Lit. The first contributor to need one should propose a location and build approach in an issue before implementing — likely either compiling through webpack alongside the rest of the page JS, or adding a second Vite entry. - The service layer is nascent —
ListService.jsis the only example today, and it lives in thelists/feature folder rather than a dedicated services folder. Treat new fetch/mutation helpers as the start of a service-layer convention.
Lit Web Components
Lit is a lightweight library for building web components with declarative templates and reactive properties. Open Library's Lit components use Shadow DOM for style encapsulation.
This section covers design-system Lit components — the pure UI layer that lives in openlibrary/components/lit/. Lit can also be used as an orchestration tool when a complex domain-aware interaction pattern benefits from component encapsulation; that's covered in the Orchestration layer section. The rules below only apply to design-system components.
The design-system contract
A design-system Lit component's job is to look and behave a certain way — not to know anything about Open Library's backend or data model. In practice:
- Interaction logic is fine. Internal state, event handling, keyboard navigation, ARIA, open/closed, debounced input — this is what components are for.
- Domain logic is kept out. No
fetchcalls, no API URLs, no app-specific data shapes. If a component needs the backend to do something, emit an event and let the host decide what to do with it.
The difference in practice: an <ol-confirm-bar> that emits ol-confirm with the selected IDs can be used in three features. One that calls fetch('/lists/add') itself has already made decisions for all of them.
If the component you need genuinely can't do its job without knowing about lists, books, users, or the API, it's not a design-system component. Build it as an orchestration Lit component in the feature folder instead — same framework, different rules.
Where components live
- Design-system components:
openlibrary/components/lit/ - Registry:
openlibrary/components/lit/index.js— every design-system component must be registered here - Reference implementation:
OlPagination.js— a complete example showing properties, events, keyboard navigation, ARIA, and scoped styles - Orchestration Lit components (with domain logic) live in a feature folder — likely under
plugins/openlibrary/js/, but the location isn't finalized. See the Orchestration layer section.
Naming conventions
- HTML tag:
ol-<name>in kebab-case (e.g.,ol-pagination,ol-read-more) - Class name: PascalCase with
Olprefix (e.g.,OlPagination) - File name: matches the class name (e.g.,
OlPagination.js)
Basic structure
import { LitElement, html, css } from "lit";
/**
* Brief description of the component.
*
* @element ol-example
* @fires ol-example-change - Fired when the value changes
*
* @example
* <ol-example label="Click me"></ol-example>
*/
class OlExample extends LitElement {
static properties = {
/** The label text to display */
label: { type: String },
/** Whether the component is disabled */
disabled: { type: Boolean, reflect: true },
};
static styles = css`
:host {
display: block;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
`;
constructor() {
super();
this.label = "";
this.disabled = false;
}
render() {
return html` <button @click=${this._handleClick}>${this.label}</button> `;
}
_handleClick() {
this.dispatchEvent(
new CustomEvent("ol-example-change", {
detail: { label: this.label },
bubbles: true,
composed: true,
}),
);
}
}
customElements.define("ol-example", OlExample);API design
Start narrow — it's easy to add, hard to remove. Only expose properties and attributes that are immediately needed.
Use kebab-case for attribute names. Lit maps camelCase properties to kebab-case attributes automatically when you set the attribute option:
/* ✅ Kebab-case, semantic names */
static properties = {
isOpen: { type: Boolean, attribute: 'is-open' },
maxResults: { type: Number, attribute: 'max-results' },
};
/* ❌ Avoid camelCase attributes */
static properties = {
isOpen: { type: Boolean, attribute: 'isOpen' },
};Boolean attributes use presence = true. Follow the HTML convention where the attribute's presence means true and its absence means false:
<!-- ✅ Boolean attribute patterns -->
<ol-dialog open>...</ol-dialog>
<!-- open = true -->
<ol-dialog>...</ol-dialog>
<!-- open = false -->
<ol-button disabled>Click</ol-button>
<!-- disabled = true -->
<!-- ❌ Don't require explicit true/false -->
<ol-dialog open="true">...</ol-dialog>Styling
Use design tokens from static/css/tokens/, not hardcoded values. CSS custom properties inherit through the Shadow DOM boundary, so tokens work directly in component styles:
static styles = css`
:host {
font-family: var(--font-body);
color: var(--color-text-primary);
}
button {
background: var(--color-primary);
border-radius: var(--border-radius-button);
}
`;See the Design Token Guide for the two-tier system and usage examples.
Accessibility
Every component must meet these requirements.
Use semantic HTML. Prefer <button>, <nav>, <a>, and other semantic elements over generic <div> and <span> elements. Semantic elements provide keyboard handling and screen reader announcements for free.
Include ARIA roles and states. Add ARIA attributes when semantic HTML alone isn't sufficient:
/* Dialog with proper ARIA */
render() {
return html`
<div role="dialog" aria-modal="true" aria-labelledby="title">
<h2 id="title">${this.heading}</h2>
<div>${this.content}</div>
</div>
`;
}
/* Expandable section */
html`
<button
aria-expanded=${this.isOpen}
aria-controls="panel"
@click=${() => this.isOpen = !this.isOpen}
>
${this.heading}
</button>
<div id="panel" ?hidden=${!this.isOpen}>
${this.content}
</div>
`;Use aria-live for dynamic content so screen readers announce changes:
html`
<div aria-live="polite" aria-atomic="true">
${this.results.length} results found
</div>
`;Use aria-busy during loading states:
html`
<div aria-busy=${this.loading}>
${this.loading
? html`<span>Loading...</span>`
: html`<ul>
${this.items.map((item) => html`<li>${item}</li>`)}
</ul>`}
</div>
`;Keyboard navigation
Prefer native elements. <button> handles Enter and Space automatically. If you must use a non-button element, add keyboard handlers:
/* ✅ Prefer native button */
html`<button @click=${this._handleClick}>Action</button>`;
/* If you must use a div, replicate button behavior */
html`
<div
role="button"
tabindex="0"
@click=${this._handleClick}
@keydown=${(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this._handleClick();
}
}}
>
Action
</div>
`;Arrow keys navigate composite widgets. For tabs, menus, and listboxes, arrow keys move between options while Tab moves focus out of the widget. Home/End jump to first/last items.
Escape closes overlays. Dialogs, dropdowns, and popovers should close on Escape:
connectedCallback() {
super.connectedCallback();
this._handleKeydown = (e) => {
if (e.key === 'Escape' && this.open) {
this.open = false;
}
};
document.addEventListener('keydown', this._handleKeydown);
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this._handleKeydown);
}Trap focus in modals. When a modal is open, Tab should cycle through focusable elements within it, not escape to the page behind.
Provide visible focus indicators. Use :focus-visible to show outlines only for keyboard users:
/* ✅ Visible focus for keyboard users */
button:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* ❌ Never do this without an alternative */
button:focus {
outline: none;
}Don't use positive tabindex values. Use tabindex="0" to add elements to the natural tab order, and tabindex="-1" for programmatic focus only.
Events
Use CustomEvent with bubbles: true and composed: true so events cross the Shadow DOM boundary:
this.dispatchEvent(
new CustomEvent("ol-book-select", {
detail: {
bookId: this.bookId,
title: this.title,
},
bubbles: true,
composed: true,
}),
);Follow the ol-<component>-<action> naming pattern:
"ol-pagination-change"; // ✅ Namespaced, descriptive
"ol-dialog-close"; // ✅
"ol-search-submit"; // ✅
"change"; // ❌ Conflicts with native event
"slideChange"; // ❌ camelCase, not standard
"update:page"; // ❌ Vue convention, not standardDocument all emitted events in the JSDoc block:
/**
* Search input with autocomplete.
*
* @element ol-search-input
* @fires ol-search-input - Fired on each keystroke. detail: { query: string }
* @fires ol-search-submit - Fired when search is submitted. detail: { query: string }
* @fires ol-search-clear - Fired when input is cleared
*/Slots
Named slots let consumers inject content without the component needing to know about it:
render() {
return html`
<div class="card">
<header><slot name="header"></slot></header>
<div class="content"><slot></slot></div>
<footer><slot name="footer"></slot></footer>
</div>
`;
}<!-- Usage -->
<ol-card>
<h3 slot="header">Book Title</h3>
<p>Description in the default slot.</p>
<button slot="footer">Borrow</button>
</ol-card>Consuming Lit components
Lit components are standard custom elements, so anything that can render HTML can use them.
From server-rendered HTML (Templetor):
<ol-pagination total="100" page="1"></ol-pagination>From a vanilla controller:
const pagination = root.querySelector("ol-pagination");
pagination.addEventListener("ol-pagination-change", (e) => {
/* ... */
});From Vue:
<ol-pagination :total="100" :page="page" @ol-pagination-change="handleChange" />Vue is encouraged to consume Lit primitives rather than re-implement them — a bespoke Vue chip or pagination control drifting visually from the design-system version is exactly the kind of fragmentation the design system is meant to prevent.
TIP
For the full component specification — including performance guidelines and additional patterns — see docs/ai/web-components.md in the codebase.
Orchestration layer: wiring the page
Once you have UI components, something has to wire them up to the rest of the app. That's the orchestration layer. It listens for events from components, holds any page-level state that doesn't belong inside a component, and calls service modules when the backend needs to be touched.
The orchestration layer is where the frontend's domain knowledge lives. You have three tools to choose from, picked by complexity.
Vanilla JavaScript controllers
Most Open Library pages are server-rendered HTML lightly enhanced with behavior — wire up a button, toggle a panel, fetch an update. A plain controller in openlibrary/plugins/openlibrary/js/<feature>/index.js exporting an init(rootEl) function is the right shape. Keep fetches inside service modules; let the controller be glue.
Lit components with domain logic
When an interaction pattern is complex enough that it benefits from component encapsulation and it needs to know about the domain, write it as a Lit component — but put it outside components/lit/. The most natural home is a feature folder under openlibrary/plugins/openlibrary/js/<feature>/components/, though no one has shipped this pattern yet and the build path needs sorting first (see the "Where we're heading" callout above). These components may import services, make network calls, and carry app-specific state. The design-system contract doesn't apply to them; they're orchestration dressed as components.
A common pattern is a domain-aware wrapper around a design-system primitive. The component below is illustrative — neither it nor its location are real in the codebase yet:
// openlibrary/plugins/openlibrary/js/my-books/components/MyBooksDeleteButton.js
import { LitElement, html } from "lit";
import { removeItem } from "../../lists/ListService";
/**
* Delete button scoped to the My Books feature. Wraps `ol-button` and
* calls `removeItem` from the List service internally.
*
* @element my-books-delete-button
* @fires my-books-delete-button-delete - Fired after the book is deleted. detail: { bookId: string }
*/
class MyBooksDeleteButton extends LitElement {
static properties = {
/** ID of the book this button will remove */
bookId: { type: String, attribute: "book-id" },
};
render() {
return html`<ol-button @ol-button-click=${this._delete}>Delete</ol-button>`;
}
async _delete() {
await removeItem(this.bookId);
this.dispatchEvent(
new CustomEvent("my-books-delete-button-delete", {
detail: { bookId: this.bookId },
bubbles: true,
composed: true,
}),
);
}
}
customElements.define("my-books-delete-button", MyBooksDeleteButton);The design-system ol-button stays pure. The domain-aware my-books-delete-button composes it and adds the service call — living in the feature folder, never promoted to the design system. It still follows the same naming, event, and documentation conventions as design-system components; only the purity rule is relaxed.
Vue components
Reach for Vue when the screen is a mini-app: stateful, multi-view, or with complex data flow between parts of the page. Existing examples: librarian merge UI, reading stats dashboard, Library Explorer, barcode scanner. A rough test — if you'd find yourself hand-rolling reactive state to coordinate the parts, Vue probably pays for itself. If not, vanilla or a domain-aware Lit component is lighter.
Two guidelines when Vue is the right fit:
- Consume Lit primitives inside Vue. Treat
ol-button,ol-chip, etc. as custom elements. Avoid re-implementing design-system widgets as Vue components. - Delegate domain work to services. Inline
fetch('/api/...')inside a Vue component carries the same smell it would inside any other orchestration code. Route it through a service module.
Existing Vue components live in openlibrary/components/ as .vue files.
Where things live
Keep the plugins/openlibrary/js/<feature>/ convention rather than co-locating JavaScript with Templetor templates.
- Pure UI →
openlibrary/components/lit/ - Domain-aware Lit components → likely
openlibrary/plugins/openlibrary/js/<feature>/components/(location not yet finalized — see callout near the top) - Page-level glue →
openlibrary/plugins/openlibrary/js/<feature>/index.js - Reusable domain logic → a named service module
Resist promoting a page-only helper to "shared" until it has a second caller.
Examples: putting the layers together
NOTE
Several components used below (ol-list-picker, ol-selectable-table, ol-button) are illustrative — they're not in the design system today. Real design-system components include ol-pagination, ol-popover, ol-chip, ol-chip-group, and ol-read-more.
Example 1: add-to-list flow
A small page-level interaction. ol-list-picker is a Lit component that lets the user choose from their lists and emits ol-list-picker-select with the chosen list. The controller owns the API call.
// openlibrary/plugins/openlibrary/js/book-actions/index.js
import { addItem } from "../lists/ListService";
export function init(root) {
const picker = root.querySelector("ol-list-picker");
picker.addEventListener("ol-list-picker-select", async (e) => {
await addItem(e.detail.listId, e.detail.bookId);
picker.close();
});
}The component knows nothing about what "adding" means. The controller knows nothing about how the picker is rendered. The service knows nothing about either.
Example 2: selectable table with a confirm bar
A richer component. ol-selectable-table manages selection state and shows a confirm bar when one or more rows are selected. It emits ol-selectable-table-confirm with the selected IDs. The page controller handles the domain side.
<ol-selectable-table .items="${rows}">
<ol-button slot="confirm-action">Delete</ol-button>
</ol-selectable-table>// openlibrary/plugins/openlibrary/js/my-books/index.js
import { removeItem } from "../lists/ListService";
export function init(root) {
const table = root.querySelector("ol-selectable-table");
table.addEventListener("ol-selectable-table-confirm", async (e) => {
for (const id of e.detail.ids) await removeItem(id);
table.clearSelection();
});
}ol-selectable-table contains real interaction logic — selection state, ARIA, keyboard handling, showing and hiding the confirm bar — but no knowledge of lists or deletion. That's how components stay reusable across features.
Service layer
The service layer is a set of plain ES modules that own the domain side: API calls, response shaping, rules that aren't about UI. ListService.js is the shape to follow. Any new fetch or mutation added from an orchestration-layer controller — vanilla or Vue — should go through a service module rather than being inlined. A deeper guide to the service layer will live elsewhere; for now, if you're about to write fetch( in a controller or Vue component, treat that as your cue to add or extend a service.
Proposing a New Component
If you think a new component is needed:
Check what exists. Look in
openlibrary/components/lit/and the Design Pattern Library. The component might already exist or a similar one could be extended.Open an issue. Describe:
- The use case — where will this component be used and why?
- A rough API sketch — what properties, events, and slots would it have?
- A mockup or screenshot if applicable
- Whether this replaces or enhances existing functionality
Get feedback before building. Component APIs are hard to change once in use. A quick discussion saves rework.