Building Components
This guide covers when and how to build interactive UI components for Open Library. It replaces the previous "Using Vue" page and reflects the current direction: Lit web components are the standard for new interactive UI.
When to Build a Component
Not every interactive element needs to be a web component. Use this decision framework:
Build a Lit web component when:
- The UI is interactive and benefits from encapsulation (its own styles, state, events)
- The element will be reused across multiple pages or contexts
- The behavior is complex enough to warrant a clean API (attributes, events, slots)
Use vanilla JavaScript when:
- The interaction is a one-off page enhancement (e.g., toggling a section)
- The behavior is simple DOM manipulation tied to a specific template
- There's no need for style encapsulation or reusability
Use a template change when:
- The change is purely visual or structural
- No client-side interactivity is needed
- Server-rendered HTML is sufficient
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.
Where components live
- Component files:
openlibrary/components/lit/ - Registry:
openlibrary/components/lit/index.js— every component must be registered here - Reference implementation:
OlPagination.js— a complete example showing properties, events, keyboard navigation, ARIA, and scoped styles
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>TIP
For the full component specification — including performance guidelines and additional patterns — see docs/ai/web-components.md in the codebase.
Vue Components
Vue is used for a few specialized, JavaScript-heavy tools:
- Librarian merge UI — complex form interactions for merging book records
- Reading stats — rich data visualization of reading activity
- Library Explorer — self-contained browsing experience
Vue is not the default for new UI. If you're considering Vue for a new feature, discuss it in the issue first. In most cases, a Lit web component is the better choice — it's lighter, uses platform standards, and doesn't require a framework runtime.
For existing Vue components, files live in openlibrary/components/ as .vue files.
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.