Skip to content
RayBB edited this page Feb 9, 2026 · 24 revisions

Frontend Guide

Welcome to the Front-End Guide for Open Library, a primer for making front-end changes to the openlibrary.org website.

File Organization Overview

  • assets: css/less in /static/css and js in /openlibrary/plugins/openlibrary/js
  • models: /openlibrary/core/ and /openlibrary/plugins/upstream/models.py, data + ORM
  • controllers: in /openlibrary/plugins/ (maps urls [via regex] → classes w/ GET + POST functions which receive/serve content to clients)
  • templates: in /openlibrary/templates and openlibrary/macros. Macros are special template components because they can be rendered (by librarians + admins) within infogami wiki pages using the syntax:
{{macros()}}

Build Process

During local development, after making edits to CSS or JS, re-compile the build/static assets using one of the following methods.

One-off build of JS, CSS, and Vue components

docker compose run --rm home npm run build-assets

One-off build each time a change is made

JS:

docker compose run --rm home make js

CSS:

docker compose run --rm home make css

Use a watch script to monitor for changes and build as necessary

JS:

docker compose run --rm home npm run-script watch

CSS:

docker compose run --rm home npm run-script watch:css

Vue:

docker compose run --rm home npx vue-cli-service build --watch --no-clean --mode production --dest static/build/components/production --target wc --name ol-CoversNew openlibrary/components/CoversNew.vue

Replace CoversNew with your component.

Note:

  • You might also need to restart the webserver and/or clear browser caches to see the changes.
  • If you want to view the generated files you will need to attach to the container (docker compose exec web bash) to examine the files in ./static the Docker volume, rather than in your local directory.
  • If you are using an Intel-based Mac and get an error building JavaScript, specifically make: *** [Makefile:24: js] Error 139, consider downgrading Docker Desktop to Docker 4.18.0. See https://github.com/docker/for-mac/issues/6824 for more.

Working with CSS

CSS Build process

Compilation to page-specific files. Stylesheets are compiled via webpack to generate individual CSS files in static/build/css/ (e.g., page-home.css, page-book.css). Each page loads the appropriate page-specific CSS file.

Check out the CSS directory README for information on render-blocking vs JS-loaded CSS files.

CSS Conventions

Write native CSS. We plan to migrate away from LESS, so avoid using LESS specific features such as mixins and color functions (e.g. lighten()).

/* ❌ Avoid LESS-specific features */

/* Mixins */
.border-radius(@radius) {
  border-radius: @radius;
}
.my-component {
  .border-radius(5px);
}

/* Color functions */
.my-button {
  background: lighten(@primary-color, 10%);
  color: fade(@text-color, 80%);
}

Use BEM notation. We use BEM for CSS class naming in most instances. The exceptions are Web Components and Vue components. The main goal of BEM is to avoid naming collisions and both Web Components (using Shadow DOM) and Vue components (with<style scoped>) provide built-in CSS encapsulation.

/* BEM Example */
/* Block */
.book-card { }

/* Element (part of the block) */
.book-card__title { }
.book-card__cover { }

/* Modifier (variation of block or element) */
.book-card--featured { }
.book-card__title--large { }

Avoid styling bare HTML elements.

/* ❌ Affects every paragraph globally */
p { margin-bottom: 1rem; }

/* ✅ Acceptable: in a prose/content context that's explicitly opted into */
.prose p { margin-bottom: 1em; }

/* ✅ Preferred: explicit class */
.book-description__text { margin-bottom: 1em; }

Avoid IDs for styling. IDs have high specificity and are meant for JavaScript hooks or anchor links, not styling.

/* ❌ High specificity, hard to override */
#main-header { }

/* ✅ Lower specificity, reusable */
.main-header { }

Avoid deep nesting. Deeply nested selectors make it difficult to trace where styles originate and lead to specificity battles. Keep nesting to a minimum.

/* ❌ Deeply nested - hard to track cascade */
.book-list .book-card .book-card__title { }

/* ✅ Flat structure - easy to find and override */
.book-card__title { }

Avoid magic numbers, use design tokens. Design tokens are used for styling properties like colors, fonts, and border radii. They make our styles consistent and easy to update globally.

Design tokens are simply a structured approach to using variables. There are two tiers in our system:

  1. Primitives are raw values like hex colors and font sizes. A small, curated set of base values that work well together.
  2. Semantic tokens map to intent. Their names communicate where they should be used. This provides a single point of control for updating categories of components.
// Primitives - DON'T use these directly
@border-radius-sm: 2px;
@border-radius-md: 4px;
@border-radius-lg: 8px;
@border-radius-xl: 16px;
@border-radius-xxl: 32px;
@border-radius-pill: 9999px;
@border-radius-circle: 50%;
@border-radius-square: 0;

// Semantic tokens - USE these
@border-radius-button: @border-radius-md; // buttons, tabs
@border-radius-input: @border-radius-md; // inputs, textareas
@border-radius-thumbnail: @border-radius-sm; // small images
@border-radius-media: @border-radius-lg; // large images, videos
@border-radius-card: @border-radius-lg; // cards
@border-radius-overlay: @border-radius-xl; // dialogs, modals
@border-radius-badge: @border-radius-sm; // badges, tags
@border-radius-notification: @border-radius-lg; // notifications, alerts
@border-radius-avatar: @border-radius-circle; // avatars, profile pictures
/* ✅ Do this */
.my-card {
  border-radius: @border-radius-card;
}

.profile-pic {
  border-radius: @border-radius-avatar;
}
/* ❌ Don't do this - Using primitives directly */
.my-card {
  border-radius: @border-radius-lg;  // Use @border-radius-card instead 
}

/* ❌ Hardcoding values */
.profile-pic {
  border-radius: 50%;  // Use @border-radius-avatar instead
}

Note

Design Tokens implementation began Dec 2025 and is WIP. See progress

Finding a css file

Use the browser's inspector developer tools to find the CSS class name and then use git grep to find the file. Note that for some elements like cta-btn--available it may be easier to search for a subpart of the class, like --available as many of our css documents have nested styles and define partial rules like &--available.

Applying CSS to Templates

Certain templates define a variable called cssfile using putctx()here are some examples on Github.

Typically, when we deal with templates on Open Library, we are dealing with content that gets loaded into the body of the site. This body is wrapped by <head> and <footer> elements defined in openlibrary/templates/site.

The way the system works is, the cssfile variable is defined by the body sub-template via putctx(), which passes the variable up to the wrapping template, which loads the corresponding CSS file in the <head> section.

Working with HTML

While running the oldev Docker container, gunicorn is configured to auto-reload modified Python files or web templates upon file save.

Note: the home page (openlibrary\templates\home) is cached and each change will take time to apply unless you run docker compose restart memcached, which restarts the memecached container and renders the change directly.

Open Library uses templetor syntax in our HTML. See its documentation first: http://webpy.org/docs/0.3/templetor

Here are some quick/useful snippets:

$# Rendering sanitized text vs. HTML; replace `$` with `$:` in any of the following statements
$cond(True, 'a < 3', '')
$# Renders as:
a &lt; 3
$:cond(True, '<li>x</li>', '')
$# Renders as:
<li>x</li>

$# Rendering other macros/templates
$:macros.EditButtons(comment="")
$:render_template("lib/pagination", pagecount, pageindex, "/admin/loans?page=%(page)s")

Working with JavaScript

Most JavaScript files for the Open Library project live in openlibrary/plugins/openlibrary/js. The main application logic is handled by ol.js. All custom JavaScript files are combined and included as all.js.

Open Library uses jQuery and Vue. Some third-party JavaScript libraries are combined and included as vendor.js.

Linking New Javascript Files to HTML Templates

This tutorial explains how to connect a JavaScript file to an HTML template in the Open Library codebase, using the 'Meet the Team' page as an example.

Step 1: Set up your environment

This tutorial assumes you already have an HTML template and a route created to display it in your browser. Run the Docker commands to start a local instance of the page.

Step 2: Find where the Javascript files live in the Open Library code

All the Javascript files can be found in a js folder. The path to that folder is openlibrary/plugins/openlibrary/js.

Step 3: Create a Javascript file

Inside the js folder, create a new JavaScript file with a meaningful name that describes its purpose and the page it attaches to.

Example: team.js

Next, examine how JavaScript files are connected to templates.

Step 4: Look at the index.js file

This file is the gateway for JavaScript files in Open Library. Around line 68, locate the comment // Initialize some things and the jQuery line that follows. The subsequent code block contains if statements that determine which JavaScript files to load based on the HTML template. An example can be found around line 490.

    // Add functionality for librarian merge request table:
    const librarianQueue = document.querySelector('.librarian-queue-wrapper')

    if (librarianQueue) {
        import(/* webpackChunkName: "merge-request-table" */'./merge-request-table')
            .then(module => {
                if (librarianQueue) {
                    module.initLibrarianQueue(librarianQueue)
                }
            })
    }

This code uses DOM manipulation to select an HTML element with the class librarian-queue-wrapper and stores it in a variable called librarianQueue. The if statement checks whether this element exists in the DOM. If it does, the code imports the JavaScript file associated with librarianQueue from './merge-request-table'.

Underneath the import statement, we have another if statement and finally, a function from the Javascript file to initialize the librarian queue. If you want, check out the merge-request-table folder to see what the functions do. Otherwise, we'll continue setting up our own Javascript in the next step.

Step 5: Create the link in the index.js

Apply the same pattern as librarianQueue for the team page. Locate the div in the team page template where the filtered cards will appear, and use it in the if statement. If the div exists, import the team JavaScript file. Note that the import path refers to the file location, not just the filename.

    // Add functionality to the team page for filtering members:
    const teamCards = document.querySelector('.teamCards_container')

    if (teamCards) {
        import('./team')
            .then(module => {
                if (teamCards) {
                    module.initTeamFilter()
                }
            })
    }

We haven't actually made the initTeamFilter function yet, so let's do that in the next step.

Step 6: Create the initTeamFilter function in team.js

This function will contain all of the Javascript the HTML template needs.

    export function initTeamFilter() {
        console.log("Hooked up")
    }

After building the JavaScript with docker compose run --rm home make js or the watch script docker compose run --rm home npm run-script watch, reload the HTML template in the browser. You should see "Hooked up" in the console.


vendor.js and third party libraries

All third-party JavaScript files are added to the vendor/js directory in the repository. The static/build/vendor.js file is generated by combining these JavaScript files, with the included files specified in the shell script static/js/vendor.jsh.

To include a new third-party library:

  1. Add that library in vendor/js in the repository
  2. Add an entry in static/js/vendor.jsh
  3. Generate vendor.js by running make:
    make js
  4. Commit vendor.jsh and the library added to the repository

all.js and custom JavaScripts

All custom JavaScript files are located at openlibrary/plugins/openlibrary/js. These files are combined to generate build/js/all.js.

The inclusion order is determined by the sort order of filenames. Avoid depending on the load order of files.

If you modify any JavaScript files, see Building CSS and JS to regenerate build/js/all.js.

Routing and Templates

  • Open Library is rendered using Templetor templates, part of the web.py framework.

  • The repository you cloned on your local machine is mounted at /openlibrary in docker. If you make template changes to files locally, the Open Library instance in the virtual machine should automatically pick up those changes.

  • The home page is rendered by templates/home/index.html, and its controller is plugins/openlibrary/home.py.

  • A books page is rendered by templates/type/edition/view.html. An edition is defined by edition type. An edition is served by a /books/OL\d+M url.

  • A works page is rendered by templates/type/work/view.html. A work is defined by work type. A work is served by a /works/OL\d+W url.

Components

As of Q3 2025, the team is considering standardizing around Lit as a lightweight framework for developing web components to manage complex user experiences. In preparation for this potential transition, existing candidate components are being documented here:

Working with Web Components

Open Library uses Lit for building web components. Lit is a lightweight library that makes it easy to create fast, reactive components with declarative templates. Web components provide true encapsulation via Shadow DOM, meaning styles don't leak in or out, no need for BEM naming conventions.

Basic Component Structure

Here's a minimal Lit component template:

import { LitElement, html, css } from 'lit';

/**
 * A brief description of what this component does.
 * 
 * @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. A minimal API is easier to maintain and less likely to break.

Use kebab-case for attribute names. HTML attributes are case-insensitive, so kebab-case is the standard convention and matches native HTML patterns.

/* ✅ Kebab-case, semantic names */
static properties = {
  isOpen: { type: Boolean, attribute: 'is-open' },
  maxResults: { type: Number, attribute: 'max-results' },
};

/* ❌ Avoid camelCase */
static properties = {
  isOpen: { type: Boolean, attribute: 'isOpen' },  // Won't work as expected
};

Boolean attributes should use presence = true. Following HTML conventions, a boolean attribute's presence means true, and its absence means false. Don't require ="true" or ="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>

HTML & Semantics

Provide accessible names. Every interactive element needs an accessible name. Prefer visible text, fall back to aria-label or aria-labelledby when needed.

/* ✅ Accessible names */
html`
  <!-- Visible text provides the name -->
  <button>Add to reading list</button>
  
  <!-- Icon-only button needs aria-label -->
  <button aria-label="Close dialog">
    <svg>...</svg>
  </button>
  
  <!-- Reference another element's text -->
  <h2 id="dialog-title">Confirm deletion</h2>
  <div role="dialog" aria-labelledby="dialog-title">...</div>
`

Accessibility

Use appropriate ARIA roles. Match the WAI-ARIA Authoring Practices for the widget pattern you're implementing. Common patterns include dialog, menu, tabs, and combobox.

/* ✅ 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>
  `;
}

Use aria-live for dynamic announcements. When content updates that users need to know about (like search results or error messages), use aria-live regions.

/* ✅ Announce search results to screen readers */
html`
  <div aria-live="polite" aria-atomic="true">
    ${this.results.length} results found
  </div>
`

Use aria-busy during loading states. This tells assistive technology to wait before announcing content.

/* ✅ Loading state */
html`
  <div aria-busy=${this.loading}>
    ${this.loading
      ? html`<span>Loading...</span>`
      : html`<ul>${this.items.map(item => html`<li>${item}</li>`)}</ul>`
    }
  </div>
`

Reflect interactive states with ARIA. Use aria-expanded, aria-selected, aria-pressed, etc. to communicate state to assistive technology.

/* ✅ 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>
`

Keyboard Navigation

Ensure tab order matches visual order. Don't use positive tabindex values. Use tabindex="0" to add non-interactive elements to the tab order, and tabindex="-1" for programmatic focus.

Enter and Space activate buttons. Native <button> elements handle this automatically. If you use a non-button element, add keyboard handlers.

/* ✅ Prefer native button */
html`<button @click=${this._handleClick}>Action</button>`

/* If you use a div */
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);
}

Provide visible focus indicators. Never remove focus outlines without providing an alternative. Use :focus-visible to show outlines only for keyboard navigation.

/* ✅ Visible focus */
button:focus-visible {
  outline: 2px solid var(--focus-color);
  outline-offset: 2px;
}

/* ❌ Never do this without an alternative */
button:focus {
  outline: none;
}

Trap focus in modal dialogs. When a modal is open, Tab should cycle through focusable elements within the modal, not escape to the page behind.

Styling

Use Open Library design tokens. Import tokens from our LESS files (colors.less, font-families.less, etc.) to maintain visual consistency. See the CSS section for details on semantic tokens.

/* ✅ Use CSS custom properties mapped from tokens */
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);
  }
`;

Events

Use CustomEvent with a detail payload. This is the standard way to emit data from a component.

/* ✅ CustomEvent with detail */
this.dispatchEvent(new CustomEvent('ol-book:selected', {
  detail: { 
    bookId: this.bookId,
    title: this.title,
  },
  bubbles: true,
  composed: true, // Crosses shadow DOM boundary
}));

Use kebab-case event names with component prefix. This prevents naming collisions and makes it clear which component emitted the event.

/* ✅ Namespaced, kebab-case */
'ol-search:submit'
'ol-dialog:close'
'ol-carousel:slide-change'

/* ❌ Avoid generic or camelCase names */
'submit'       // Conflicts with native event
'slideChange'  // Inconsistent with HTML conventions

Document all emitted events. Include events in the component's JSDoc so consumers know what to listen for.

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

Use slots for flexible content composition. Slots allow consumers to inject content into your component.

/* ✅ Named and default slots */
render() {
  return html`
    <div class="card">
      <header>
        <slot name="header"></slot>
      </header>
      <div class="content">
        <slot></slot> <!-- Default slot -->
      </div>
      <footer>
        <slot name="footer"></slot>
      </footer>
    </div>
  `;
}
<!-- Usage -->
<ol-card>
  <h3 slot="header">Book Title</h3>
  <p>Main content goes in the default slot.</p>
  <button slot="footer">Borrow</button>
</ol-card>

Note

Web component adoption at Open Library began in 2024 and is ongoing. When modifying existing jQuery/vanilla JS widgets, consider whether migrating to a web component would be beneficial.

URL Routing

Are you trying to find an existing Router within our plugins/? You can try to lookup the url pattern here or via https://dev.openlibrary.org/developers/routes to find out which view class is responsible for generating the template with which you wish to work. Some routes may pass through Open Library (to Infogami) and actually be handled generically by Infogami. This is true for routes like /books/OL..M/:title whose route patterns you can see registered at the bottom of openlibrary/core/models.py. You may note, most of the url routing is handled within openlibrary/plugins. Each view class specifies whether it returns json or if it returns a template. If it returns a template, the first argument should be the template's path (relative to the templates/ directory) where it lives. The values following the template name are variables passed into the template (that the template will have access to).

The Lifecycle of a Network Request

The lifecycle of an Open Library network request.

Adding a new Router

To add a new Router to Open Library, refer to the tutorial in our plugins README.

Partials

In some cases, a page should load quickly with a minimal template, then fetch additional components using JavaScript after the initial page load. For example, on the books page, book prices in the sidebar can be slow to load. Instead of fetching this data on the backend and returning it with the template, the template includes JavaScript instructions to load a Partial after the minimal book page finishes loading:

Screenshot 2024-05-07 at 11 44 58 AM

A Partial is a targeted endpoint that returns only the minimal html required for rendering a specific, isolated component, such as a "related books carousel" or a "book price widget".

In PR #8824 you can see a complete example of where the "blocking" synchronous book price widget was removed from the Book Page and replaced with an asynchronous javascript call to a Partial that fetched book data after page load.

In order for the data-infused partial to be rendered within a template, you’ll need to edit &/or create the following files and understand how they need to interact:

What files do I need?

  1. The template - the html file in the templates folder where you intend the partial to render
  2. The partial - an html file in the macros folder
  3. The partial’s JS - a js file in the js folder that makes the call to the partials endpoint and receives the data to load into the partial
  4. index.js - the js file that connects the partial to its JS file
  5. partials.py - the python file where the Partials endpoint lives. The JS file makes a call to this endpoint, passing in the macro or placeholder. The GET function makes a call to the backend, then calls the macro with the returned data, and returns the data-infused macro back to the partial’s JS.

How to get these files to interact?

  1. Connect the Template to the Partial (2 methods)

    • Placeholder: Create an HTML element with an id in the file where you’d like the Partial to render.
    • Direct call: Directly call the partial in the template, passing in the parameters
  2. Connect the Partial placeholder or the Partial itself to the Partial’s JS

    • This connection takes place in index.js
    • The element with the id we assigned to the placeholder/partial above is selected
    • The partial's JS file is imported and the init function called
  3. Connect the Partial’s JS to the Partial endpoint

    • The Partial’s JS makes a call to the Partial endpoint. The call returns the data-infused partial to the partial’s js where it’s added to the template or the placeholder on the template.

Working in Docker

Refer to: https://github.com/internetarchive/openlibrary/blob/master/docker/README.md

While running the oldev container, Gunicorn automatically reloads modified files. To see the effects of your changes:

  • Editing python files or web templates: see Working with HTML.
  • Working on frontend CSS or JS: see Building CSS and JS.
  • Adding or changing core dependencies => you will most likely need to rebuild both olbase and oldev images. This shouldn't happen too frequently. If you are making this sort of change, you will know exactly what you are doing.

Beware of bundle sizes

When adding CSS content, you may encounter an error like this:

 FAIL static/build/page-plain.css: 18.81KB > maxSize 18.8KB (gzip)

This indicates that your changes have increased the CSS payload beyond the allowed limit. This can lead to performance degradation and should be avoided unless well justified.

These problems are especially a concern for CSS files on the critical path. Always consider placing styles in an JavaScript entrypoint file e.g. <file_name>--js.less and load it inside static/css/js-all.less via @import. This CSS will only get loaded via JavaScript and has a much higher bundlesize threshold.

Browser Support

We support both Firefox and Chromium-based browsers on desktop and mobile (IOS and Android).

Internet Explorer 11

As of May 14, 2021, Internet Explorer accounts for 0.32% of traffic (~1,172 patrons), ranking 10th behind Opera, Amazon Silk, and YaBrowser. Nearly all IE traffic is from IE11.

In short:

  • Things should function in IE11
  • It is alright if things look wonky in IE11

We will move away from IE11 support at some point, just like Internet Archive did.

Relevant Thread

Clone this wiki locally