Skip to content

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 Ol prefix (e.g., OlPagination)
  • File name: matches the class name (e.g., OlPagination.js)

Basic structure

js
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:

js
/* ✅ 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:

html
<!-- ✅ 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:

js
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:

js
/* 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:

js
html`
  <div aria-live="polite" aria-atomic="true">
    ${this.results.length} results found
  </div>
`;

Use aria-busy during loading states:

js
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:

js
/* ✅ 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:

js
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:

css
/* ✅ 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:

js
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:

js
"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 standard

Document all emitted events in the JSDoc block:

js
/**
 * 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:

js
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>
  `;
}
html
<!-- 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:

  1. 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.

  2. 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
  3. Get feedback before building. Component APIs are hard to change once in use. A quick discussion saves rework.