Skip to content

building components

Lokesh Dhakar edited this page Mar 20, 2026 · 3 revisions

order: 4

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

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 standard

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

  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.

Clone this wiki locally