It looks like you're offline.
Open Library logo

Components

Actions

Button <ol-button>

Avoid: For a state that stays on or off, use Toggle. For a filter that can be removed, use Chip.

Variants

Secondary is the default — a bare <ol-button> renders as secondary. Variant carries emphasis and nothing else; hue is a separate axis, tone, below.

Primary Secondary Ghost
<ol-button variant="primary">
    Primary
</ol-button>
<ol-button>
    Secondary
</ol-button>
<ol-button variant="ghost">
    Ghost
</ol-button>

Danger tone

tone="danger" crosses the danger hue with the emphasis you picked. Beside a Save, stay quiet with ghost or secondary; in the confirmation that follows, Delete is the primary action and takes the fill.

Primary Secondary Ghost
<ol-button variant="primary" tone="danger">
    Primary
</ol-button>
<ol-button variant="secondary" tone="danger">
    Secondary
</ol-button>
<ol-button variant="ghost" tone="danger">
    Ghost
</ol-button>

Links

Set href and the component renders an <a> with the same styling — for button-shaped navigation like Read, Borrow, or Nearby Libraries. disabled and loading drop the href and set aria-disabled.

Read Borrow Nearby Libraries Unavailable
<ol-button variant="primary" href="/search?q=hobbit">
    Read
</ol-button>
<ol-button href="/search?q=hobbit">
    Borrow
</ol-button>
<ol-button href="/search?q=hobbit" target="_blank" rel="noopener">
    Nearby Libraries
</ol-button>
<ol-button href="/search?q=hobbit" disabled>
    Unavailable
</ol-button>

Icon-only shapes

shape="icon" makes a square whose side matches the size's control height; shape="circle" rounds it. Always give these an aria-label.

<ol-button shape="icon" aria-label="Search">
  <svg ...></svg>
</ol-button>
<ol-button shape="circle" size="small" aria-label="Save">
  <svg ...></svg>
</ol-button>

Floating

elevation="floating" swaps in a heavier drop shadow for a control that sits over content — a save button on cover art, a scroll-to-top button — instead of on the page surface.

Back to top
<ol-button shape="circle" elevation="floating" aria-label="Save">
    <svg width="16"
         height="16"
         viewBox="0 0 24 24"
         fill="none"
         stroke="currentColor"
         stroke-width="2"
         stroke-linecap="round"
         stroke-linejoin="round"
         aria-hidden="true">
        <path d="M12 5v14M5 12h14" />
    </svg>
</ol-button>
<ol-button elevation="floating">
    Back to top
</ol-button>

Sizes

Heights come from the shared --control-height-* tokens, so each size lines up with the same-size segmented control and input.

Small Medium Large
<ol-button size="small">
    Small
</ol-button>
<ol-button>
    Medium
</ol-button>
<ol-button size="large">
    Large
</ol-button>

With icon

Put an inline SVG in the icon-start or icon-end slot. The button sizes it to match (14/16/18px by size) and adds the gap, so the SVG needs no width, height, or margin of its own.

Preview Search Inside Next
<ol-button size="small">
  <svg slot="icon-start" ...></svg>
  Preview
</ol-button>

<ol-button>
  Next
  <svg slot="icon-end" ...></svg>
</ol-button>

Disabled

Primary Secondary Delete
<ol-button variant="primary" disabled>
    Primary
</ol-button>
<ol-button disabled>
    Secondary
</ol-button>
<ol-button variant="primary" tone="danger" disabled>
    Delete
</ol-button>

Loading

Shows a spinner and blocks interaction. The label stays in the DOM so the button's width doesn't shift.

Save Cancel Delete
<ol-button variant="primary" loading>
    Save
</ol-button>
<ol-button loading>
    Cancel
</ol-button>
<ol-button variant="primary" tone="danger" loading>
    Delete
</ol-button>

Loading, driven from JS

Click for a 1.5s fake request. Setting .loading disables the button for the duration.

Save
button.loading = true;
await save();
button.loading = false;

Full width

Continue
<ol-button full-width>
    Continue
</ol-button>

Form submission

The element is form-associated and keeps a hidden native submit button in its light DOM, so it behaves like a native button: type="submit" submits the enclosing form (native validation runs, Enter in a text field submits, event.submitter is set, name/value are submitted, formaction and friends work), type="reset" resets it, and preventDefault() on a click cancels either. A disabled fieldset disables it too.

Submit Reset

Disable fieldset

Last submitted: — (submits: 0)

<form id="demo-button-form">
    <fieldset id="demo-button-form-fieldset" class="ds-demo-row">
        <label for="demo-button-form-input">Your name:</label>
        <input id="demo-button-form-input" name="name" required>
        <label for="demo-button-form-input-2">Nickname:</label>
        <input id="demo-button-form-input-2" name="nickname">
        <ol-button type="submit" name="action" value="save">
            Submit
        </ol-button>
        <ol-button type="reset" variant="secondary">
            Reset
        </ol-button>
    </fieldset>
</form>
<p>
    <ol-toggle id="demo-button-form-disable" variant="button">
        Disable fieldset
    </ol-toggle>
</p>
<p>Last submitted: <strong id="demo-button-form-output">—</strong> (submits: <span id="demo-button-form-count">0</span>)</p>
<script>
    (function () {
        const form = document.getElementById('demo-button-form');
        const out = document.getElementById('demo-button-form-output');
        const count = document.getElementById('demo-button-form-count');
        const fieldset = document.getElementById('demo-button-form-fieldset');
        let submits = 0;
        form.addEventListener('submit', function (e) {
            e.preventDefault();
            count.textContent = ++submits;
            // Pass the submitter so its name/value are included, as they would be on a real submission.
            const data = new FormData(form, e.submitter);
            const via = e.submitter ? e.submitter.closest('ol-button').textContent.trim() : 'no submitter';
            out.textContent = (data.get('name') || '—') + ' [' + data.get('action') + ', via ' + via + ']';
        });
        form.addEventListener('reset', function () { out.textContent = '—'; });
        document.getElementById('demo-button-form-disable').addEventListener('ol-toggle-change', function (e) {
            fieldset.disabled = e.detail.checked;
        });
    })();
</script>

Styling from outside

Two parts are exposed: control (the inner button or link) and label (the span around the slotted content). Use them for layout tweaks a consumer legitimately owns — like clamping a long label — never to restyle the button itself.

A very long label that gets clamped
.filter-trigger ol-button::part(label) {
  max-width: 12ch;
  overflow: hidden;
  text-overflow: ellipsis;
}

Events. None custom — use the native click, which bubbles from the inner <button>.

Rendering. Shadow DOM. The component renders and paints a real <button> (or <a>) in its shadow root, so it works inside any other component's shadow root too. Before upgrade the host tag is styled by components/ol-button.css (ol-button:not(:defined), loaded render-blocking site-wide) so server-rendered buttons look right on first paint. Style from outside via ::part(control) and ::part(label), never by reaching for the inner element.

API reference
Properties
PropertyAttributeTypeDefaultDescription
variant variant "primary" | "secondary" | "ghost" 'secondary' Emphasis only, never hue. Default: "secondary". Ghost is transparent with no border or lift; it fills on hover.
size size "small" | "medium" | "large" 'medium' Default: "medium"
type type "button" | "submit" | "reset" 'button' Default: "button"
loading loading Boolean false Shows a spinner and disables interaction.
disabled disabled Boolean false Disables interaction.
fullWidth full-width Boolean false Button expands to fill its container.
tone tone "danger" — Crosses the danger hue with the chosen emphasis, for actions that destroy something. `secondary` and `ghost` carry the red in the label and fill on hover; `primary` is the solid red fill. Save the fill for the confirmation, where deleting *is* the primary action.
shape shape "icon" | "circle" — Icon-only: width equals the size's height, no horizontal padding. "circle" additionally rounds it. Give it an aria-label.
elevation elevation "floating" — Heavier drop shadow for a control that sits over content (e.g. a save button on cover art) rather than on the page.
name name string — Submitted with `value` when this button submits the form.
value value String — See `name`.
href href String — Renders an <a> instead of a <button>.
target target String — Link target (only with href).
rel rel String — Link rel (only with href).
download download String — Link download attribute (only with href).
isDisabled — — — Whether the control is disabled from either source: its own `disabled` property or an ancestor `<fieldset disabled>`. Use this — not `disabled` — to gate interaction and to set `?disabled` on inner controls.
formAssociatedValue — — — Override point. The value(s) to submit with the form.
Slots
SlotDescription
(default) Default slot carries the button label.
icon-start Leading icon (an inline SVG). Sized to the button's size (14/16/18px) and separated from the label by a 4px gap only when filled.
icon-end Trailing icon, same treatment. Not for the disclosure chevron, which is automatic on popover triggers.
CSS parts
PartDescription
control The inner <button> or <a>.
label The span wrapping the slotted label.

Toggle <ol-toggle>

Avoid: For picking one of several options use Segmented Control.

Default

The switch, a label, and an optional greyed sublabel.

<ol-toggle label="Readable Only" sublabel="4.6M">
</ol-toggle>
<ol-toggle label="Readable Only" sublabel="4.6M" checked>
</ol-toggle>

Button variant

A bordered, raised container styled like ol-button variant="secondary". When checked it fills with a soft blue tint, the same selected fill ol-button[selected] uses.

<ol-toggle variant="button" label="Readable Only" sublabel="4.6M">
</ol-toggle>
<ol-toggle variant="button" label="Readable Only" sublabel="4.6M" checked>
</ol-toggle>

Custom label content

Put content in the default slot instead of using label / sublabel. Supply accessible-label so the switch still has an accessible name.

Dark mode — easier on the eyes
<ol-toggle accessible-label="Dark mode">
    <strong>Dark mode</strong> — easier on the eyes
</ol-toggle>

Disabled

<ol-toggle label="Readable Only" sublabel="4.6M" disabled>
</ol-toggle>
<ol-toggle variant="button" label="Readable Only" sublabel="4.6M" checked disabled>
</ol-toggle>

Change event

Toggling fires ol-toggle-change with the new state in event.detail.checked.

Checked: false

<ol-toggle id="demo-toggle" variant="button" label="Readable Only" sublabel="4.6M">
</ol-toggle>
<p>Checked: <strong id="demo-toggle-state">false</strong></p>
<script>
    document.getElementById('demo-toggle').addEventListener('ol-toggle-change', (e) => {
        document.getElementById('demo-toggle-state').textContent = e.detail.checked;
    });
</script>
API reference
Properties
PropertyAttributeTypeDefaultDescription
formAssociatedValue — — — Override point. The value(s) to submit with the form.
checked checked Boolean false On/off state. Default false.
disabled disabled Boolean false Disables interaction. Default false.
variant variant "button" — Omit for the default (plain) toggle, or "button" for a bordered, raised container styled like ol-button[variant="secondary"] (subtle drop shadow, inset specular edge on hover) that fills with a soft blue tint when checked.
label label String — Primary label text.
sublabel sublabel String — Secondary greyed text shown after the label.
accessibleLabel accessible-label String — Override aria-label on the switch. Needed when supplying label content via the default slot.
value value String 'on' Value submitted when checked. Default "on".
name name string — Form field name. When set and the toggle is checked, it submits with the enclosing `<form>` (see FormAssociatedMixin).
isDisabled — — — Whether the control is disabled from either source: its own `disabled` property or an ancestor `<fieldset disabled>`. Use this — not `disabled` — to gate interaction and to set `?disabled` on inner controls.
Events
EventTypeDescription
ol-toggle-change CustomEvent Fired on toggle. detail: { checked: Boolean }
Slots
SlotDescription
(default) Custom label content. Overrides the label/sublabel properties when provided (they render as the slot's fallback content).

Segmented Control <ol-segmented-control>

Avoid: More than about four options belong in a Select Popover.

Options are <ol-segment> children with a value; their text is the label. The component reads them once and renders accessible radios — no per-option wiring. Exactly one option is always selected.

Default

Grid List Compact
<ol-segmented-control value="list" accessible-label="View">
    <ol-segment value="grid">
        Grid
    </ol-segment>
    <ol-segment value="list">
        List
    </ol-segment>
    <ol-segment value="compact">
        Compact
    </ol-segment>
</ol-segmented-control>

Sizes

Heights track the shared --control-height-* tokens, so each size lines up with the same-size button.

Grid List Grid List Grid List
<ol-segmented-control size="small" accessible-label="View (small)">
    <ol-segment value="grid">
        Grid
    </ol-segment>
    <ol-segment value="list">
        List
    </ol-segment>
</ol-segmented-control>
<ol-segmented-control accessible-label="View (medium)">
    <ol-segment value="grid">
        Grid
    </ol-segment>
    <ol-segment value="list">
        List
    </ol-segment>
</ol-segmented-control>
<ol-segmented-control size="large" accessible-label="View (large)">
    <ol-segment value="grid">
        Grid
    </ol-segment>
    <ol-segment value="list">
        List
    </ol-segment>
</ol-segmented-control>

Icons

A segment's content can be markup instead of text — use <ol-icon>, not the icon macro, since the control re-renders each segment inside its shadow root. Give icon-only segments a label attribute so the radio is named. Icons inherit the segment's color, so they track selected and hover states for free.

<ol-segmented-control value="grid" accessible-label="View">
  <ol-segment value="grid" label="Grid">
    <ol-icon name="layout-grid"></ol-icon>
  </ol-segment>
  <ol-segment value="list" label="List">
    <ol-icon name="list"></ol-icon>
  </ol-segment>
</ol-segmented-control>

Disabled

Disable the whole control, or a single option with disabled on its <ol-segment>.

Grid List Day Week Year
<ol-segmented-control disabled accessible-label="Disabled control">
    <ol-segment value="grid">
        Grid
    </ol-segment>
    <ol-segment value="list">
        List
    </ol-segment>
</ol-segmented-control>
<ol-segmented-control accessible-label="Range">
    <ol-segment value="day">
        Day
    </ol-segment>
    <ol-segment value="week">
        Week
    </ol-segment>
    <ol-segment value="year" disabled>
        Year
    </ol-segment>
</ol-segmented-control>

Full width

Options share the container width equally.

Ebook Audiobook Print
<ol-segmented-control full-width accessible-label="Format">
    <ol-segment value="ebook">
        Ebook
    </ol-segment>
    <ol-segment value="audiobook">
        Audiobook
    </ol-segment>
    <ol-segment value="print">
        Print
    </ol-segment>
</ol-segmented-control>

Change event

Selecting fires ol-segmented-control-change with the new value in event.detail.value. Click, or focus the control and use the arrow / Home / End keys.

Grid List Compact

Selected value: grid

<ol-segmented-control id="demo-segmented" accessible-label="View">
    <ol-segment value="grid">
        Grid
    </ol-segment>
    <ol-segment value="list">
        List
    </ol-segment>
    <ol-segment value="compact">
        Compact
    </ol-segment>
</ol-segmented-control>
<p>Selected value: <strong id="demo-segmented-output">grid</strong></p>
<script>
    document.getElementById('demo-segmented').addEventListener('ol-segmented-control-change', (e) => {
        document.getElementById('demo-segmented-output').textContent = e.detail.value;
    });
</script>

Keyboard & accessibility. Renders as a radiogroup of radios. Tab moves into the selected option; arrow keys and Home / End move the selection between enabled options, wrapping at the ends. Disabled options are skipped. Always provide an accessible-label.

API reference
Properties
PropertyAttributeTypeDefaultDescription
formAssociatedValue — — — Override point. The value(s) to submit with the form.
value value String — The selected option's value. Reflected; defaults to the first enabled option.
size size "small" | "medium" | "large" 'medium' Default: "medium"
disabled disabled Boolean false Disables the whole control.
fullWidth full-width Boolean false Stretch to fill the container; options share the width equally.
accessibleLabel accessible-label String — aria-label for the radio group. Default: none.
name name string — Form field name. When set, the selected `value` submits with the enclosing `<form>` (see FormAssociatedMixin).
isDisabled — — — Whether the control is disabled from either source: its own `disabled` property or an ancestor `<fieldset disabled>`. Use this — not `disabled` — to gate interaction and to set `?disabled` on inner controls.
Events
EventTypeDescription
ol-segmented-control-change CustomEvent Fired on user selection. detail: { value: String }
Slots
SlotDescription
(default) One or more <ol-segment value="…">Label</ol-segment> option elements. A segment's content may be text or markup (e.g. an icon); add a `label` attribute on icon-only segments for the accessible name.

Chip <ol-chip>

Avoid: A chip is a removable or selectable filter. A one-shot action is a Button.

Default

Medium is the default size.

Fiction Science History
<ol-chip>
    Fiction
</ol-chip>
<ol-chip>
    Science
</ol-chip>
<ol-chip>
    History
</ol-chip>

Selected

A selected chip shows a checkmark and switches to the active color scheme.

Fiction History Science
<ol-chip>
    Fiction
</ol-chip>
<ol-chip selected>
    History
</ol-chip>
<ol-chip>
    Science
</ol-chip>

Small size

Poetry Drama Essays
<ol-chip size="small">
    Poetry
</ol-chip>
<ol-chip size="small" selected>
    Drama
</ol-chip>
<ol-chip size="small">
    Essays
</ol-chip>

With count

Fiction History
<ol-chip count="1,024">
    Fiction
</ol-chip>
<ol-chip count="76" selected>
    History
</ol-chip>

As a link

With href the chip renders as an anchor instead of a button.

Fiction History
<ol-chip href="/subjects/fiction" count="1,024">
    Fiction
</ol-chip>
<ol-chip href="/subjects/history" count="76" selected>
    History
</ol-chip>

Domain variants

Colors a chip by the kind of thing it represents. The chip maps the variant to a soft-tint palette internally — see the chip tokens under Foundations → Colors.

English Science Fiction Memoir Ursula K. Le Guin San Francisco Read now
<ol-chip variant="language">
    English
</ol-chip>
<ol-chip variant="subject">
    Science Fiction
</ol-chip>
<ol-chip variant="genre">
    Memoir
</ol-chip>
<ol-chip variant="author">
    Ursula K. Le Guin
</ol-chip>
<ol-chip variant="place">
    San Francisco
</ol-chip>
<ol-chip variant="neutral">
    Read now
</ol-chip>

Removable filter pills

variant plus selected makes a removable, category-colored filter pill. The selected state adds a close icon; listen for ol-chip-select to remove the filter.

Read now English French
<ol-chip variant="neutral" size="small" selected>
    Read now
</ol-chip>
<ol-chip variant="language" size="small" selected>
    English
</ol-chip>
<ol-chip variant="language" size="small" selected>
    French
</ol-chip>

Interactive toggle

Clicking fires ol-chip-select. The chip does not toggle itself — flip .selected in your handler.

Toggle me Toggle me too
chip.addEventListener('ol-chip-select', (e) => {
  e.target.selected = !e.target.selected;
});
API reference
Properties
PropertyAttributeTypeDefaultDescription
selected selected Boolean false Whether the chip is in a selected state
size size "small" | "medium" 'medium' Default: "medium"
variant variant "language" | "subject" | "genre" | "author" | "place" | "neutral" — Domain category that tints the chip. Omit for the default (white / solid-blue-when-selected) chip. The chip maps the variant to a soft-tint palette internally (see colors.css); a variant chip keeps its tint when `selected` and just gains a close icon.
href href String — When set, the chip renders as a link
count count String — Optional count displayed to the right of the label
accessibleLabel accessible-label String — Override aria-label on the inner interactive element
Events
EventTypeDescription
ol-chip-select CustomEvent Fired on click. detail: { selected: Boolean }
Slots
SlotDescription
(default) The chip's label content

Chip Group <ol-chip-group>

Avoid: Only for laying out Chips; don't wrap other controls in it.

Default

Chips are spaced with an 8px gap and wrap when they exceed the container width.

Fiction Science History Poetry Drama
<ol-chip-group>
    <ol-chip>
        Fiction
    </ol-chip>
    <ol-chip>
        Science
    </ol-chip>
    <ol-chip>
        History
    </ol-chip>
    <ol-chip>
        Poetry
    </ol-chip>
    <ol-chip>
        Drama
    </ol-chip>
</ol-chip-group>

Gap

small is 4px, large is 12px.

Fiction Science History Fiction Science History
<ol-chip-group gap="small">
    <ol-chip size="small">
        Fiction
    </ol-chip>
    <ol-chip size="small">
        Science
    </ol-chip>
    <ol-chip size="small">
        History
    </ol-chip>
</ol-chip-group>
<ol-chip-group gap="large">
    <ol-chip>
        Fiction
    </ol-chip>
    <ol-chip>
        Science
    </ol-chip>
    <ol-chip>
        History
    </ol-chip>
</ol-chip-group>

Wrapping

Narrow the container and chips flow onto the next line.

Fiction Science History Poetry Drama Essays Biography
<ol-chip-group>
    <ol-chip>
        Fiction
    </ol-chip>
    <ol-chip>
        Science
    </ol-chip>
    <ol-chip>
        History
    </ol-chip>
    <ol-chip>
        Poetry
    </ol-chip>
    <ol-chip>
        Drama
    </ol-chip>
    <ol-chip>
        Essays
    </ol-chip>
    <ol-chip>
        Biography
    </ol-chip>
</ol-chip-group>

Mixed chip states

Any combination of sizes, states, and link chips works.

Fiction History Science Poetry
<ol-chip-group>
    <ol-chip href="/subjects/fiction" count="1,024">
        Fiction
    </ol-chip>
    <ol-chip href="/subjects/history" count="76" selected>
        History
    </ol-chip>
    <ol-chip href="/subjects/science" count="512">
        Science
    </ol-chip>
    <ol-chip href="/subjects/poetry" count="33">
        Poetry
    </ol-chip>
</ol-chip-group>
API reference
Properties
PropertyAttributeTypeDefaultDescription
gap gap "small" | "medium" | "large" 'medium' "small" (4px), "medium" (8px, default), or "large" (12px)
Slots
SlotDescription
(default) One or more <ol-chip> elements

Pagination <ol-pagination>

Avoid: For an open-ended feed, load more in place instead of paging.

Event-based

Clicking a page fires update:page with the page number in event.detail. The component does not move itself — set current-page in your handler.

Selected page: 1

<ol-pagination id="demo-pagination" total-pages="10" current-page="1">
</ol-pagination>
<p>Selected page: <strong id="demo-selected-page">1</strong></p>
<script>
    document.getElementById('demo-pagination').addEventListener('update:page', (e) => {
        document.getElementById('demo-selected-page').textContent = e.detail;
        e.target.setAttribute('current-page', e.detail);
    });
</script>

Sizes

Use size to match the surrounding controls. Heights track the shared --control-height-* tokens, so each size lines up with the same-size button. Medium is the default.

<ol-pagination size="small" total-pages="10" current-page="3" label-pagination="Pagination sizes, small">
</ol-pagination>
<ol-pagination total-pages="10" current-page="3" label-pagination="Pagination sizes, medium">
</ol-pagination>

URL-based navigation

With base-url the component renders anchors instead, for SEO-friendly links.

<ol-pagination total-pages="50" current-page="5" base-url="/search?q=example">
</ol-pagination>

Edges and ellipsis

Arrows drop out at the first and last page; long ranges collapse to an ellipsis.

<ol-pagination total-pages="20" current-page="1">
</ol-pagination>
<ol-pagination total-pages="20" current-page="10">
</ol-pagination>
<ol-pagination total-pages="20" current-page="20">
</ol-pagination>
<ol-pagination total-pages="3" current-page="2">
</ol-pagination>
<ol-pagination total-pages="5" current-page="3">
</ol-pagination>
<ol-pagination total-pages="100" current-page="50">
</ol-pagination>

Arrows mode

When the total isn't known, show only previous/next. has-next-page controls whether the forward arrow is live.

<ol-pagination mode="arrows" current-page="3" has-next-page>
</ol-pagination>
<ol-pagination mode="arrows" current-page="3">
</ol-pagination>
<ol-pagination mode="arrows" current-page="1" has-next-page>
</ol-pagination>
API reference
Properties
PropertyAttributeTypeDefaultDescription
mode mode "full" | "arrows" 'full' Display mode: "full" (default) shows page numbers with arrows, "arrows" shows only previous/next arrows (useful when total is unknown)
size size "small" | "medium" 'medium' Default: "medium". Heights track the shared control-height tokens, so a same-size button lines up.
totalPages total-pages Number 1 Total number of pages (required for "full" mode)
currentPage current-page Number 1 Currently selected page (1-indexed)
hasNextPage has-next-page Boolean false Whether a next page exists. Only used in "arrows" mode. In "full" mode, this is derived from totalPages.
baseUrl base-url String '' Optional base URL for generating page links. When omitted, falls back to the current page URL, preserving all existing query parameters (similar to changequery). Always renders anchor tags for SEO-friendly navigation.
labelPreviousPage label-previous-page String 'Go to previous page' Aria label for "previous page" button (default: "Go to previous page")
labelNextPage label-next-page String 'Go to next page' Aria label for "next page" button (default: "Go to next page")
labelGoToPage label-go-to-page String 'Go to page {page}' Aria label template for page buttons, use {page} as placeholder (default: "Go to page {page}")
labelCurrentPage label-current-page String 'Page {page}, current page' Aria label template for current page, use {page} as placeholder (default: "Page {page}, current page")
labelPagination label-pagination String 'Pagination' Aria label for the navigation landmark (default: "Pagination")
Events
EventTypeDescription
ol-pagination-change — Fired when a page is selected. detail: { page: Number }

Overlays

Tooltip <ol-tooltip>

Avoid: Never put essential information or interactive content in a tooltip.

Default

Hover or focus the trigger. Placement is top with a 150ms show delay.

<ol-tooltip content="Edit this item">
    <button class="demo-btn">Edit</button>
</ol-tooltip>
<ol-tooltip content="Delete permanently">
    <button class="demo-btn">Delete</button>
</ol-tooltip>
<ol-tooltip content="Share with others">
    <button class="demo-btn">Share</button>
</ol-tooltip>

Placement

Flips automatically near viewport edges. Add arrow to show the directional arrow, hidden by default.

<ol-tooltip content="I'm on top" placement="top" arrow>
    <button class="demo-btn">top</button>
</ol-tooltip>
<ol-tooltip content="I'm on the bottom" placement="bottom" arrow>
    <button class="demo-btn">bottom</button>
</ol-tooltip>
<ol-tooltip content="I'm on the left" placement="left" arrow>
    <button class="demo-btn">left</button>
</ol-tooltip>
<ol-tooltip content="I'm on the right" placement="right" arrow>
    <button class="demo-btn">right</button>
</ol-tooltip>

Rich content via slot

Use slot="content" for HTML inside the tooltip.

Keyboard shortcut: Ctrl+K
<ol-tooltip>
    <button class="demo-btn">Info</button>
    <span slot="content">Keyboard shortcut: <strong>Ctrl+K</strong></span>
</ol-tooltip>

Book hovercard

Slotted content lives in the light DOM, so page CSS styles it. Inherited properties like color cascade in from the tooltip; structural styles come from the consuming page's stylesheet.

Statistical methods for planners
Statistical methods for planners (1980)
Prescott's Microbiology
Prescott's Microbiology (2013)
Encyclopaedia of hand-weaving
Encyclopaedia of hand-weaving (1959)
<ol-tooltip placement="bottom" arrow>
  <img class="book-tip__trigger"
        width="120"
        height="180"
        src="…"
        alt="Cover">
  <div slot="content" class="book-tip">…</div>
</ol-tooltip>
API reference
Properties
PropertyAttributeTypeDefaultDescription
content content String '' Text content of the tooltip
placement placement String 'top' Preferred placement relative to the trigger. Format: "{side}" or "{side}-{align}" where side is "top", "bottom", "left", or "right" and align is "start", "center", or "end". Default: "top"
showDelay show-delay Number 150 Milliseconds to wait before showing (default: 150). Set to 0 for an instant tooltip.
hideDelay hide-delay Number 0 Milliseconds to wait before hiding (default: 0)
offset offset Number 4 Gap in px between trigger and tooltip (default: 4)
arrow arrow Boolean false Shows the directional arrow (hidden by default)
disabled disabled Boolean false Prevents the tooltip from showing
Events
EventTypeDescription
ol-tooltip-show CustomEvent Fired when the tooltip opens
ol-tooltip-hide CustomEvent Fired when the tooltip closes
Slots
SlotDescription
(default) The trigger element (button, icon, link, etc.)
content Optional rich HTML tooltip content (overrides the content attribute)

Popover <ol-popover>

Avoid: Use the composed variants (Select, Options, Menu) before a bare Popover; reserve it for custom panel content.

Custom panel content

A bare ol-popover holds whatever you slot in — here a short list of links. Rows follow the menu-row contract in tokens/control-heights.css (--menu-row-height, --menu-row-inset, --menu-row-padding-inline), so they sit inset from the panel and hover as a rounded pill, never as a band running edge to edge. For a list of actions, reach for Menu Popover instead; it does this for you.

Options
<ol-popover aria-label="Book">
  <ol-button slot="trigger">Options</ol-button>
  <nav>
    <a href="…">Edit</a>
    <a href="…">History</a>
    <hr>
    <a href="…">Share</a>
  </nav>
</ol-popover>

Placement

The default is bottom-start: the panel shares the trigger's leading edge and is never narrower than it, so extra width grows to one side instead of straddling the control. Reserve bottom-center for icon-only or very small triggers, and bottom-end for triggers flush against a right edge. When only part of a control opens the popover, such as a split button's caret, anchor to the whole control so the panel lines up under it. transform-origin follows placement, so the animation always expands from the trigger.

bottom-start top-center
<ol-popover placement="bottom-start" aria-label="Actions">
        <ol-button slot="trigger">
            bottom-start
        </ol-button>
            <nav class="ds-demo-menu"
     style="min-width: 160px"
     aria-label="Book">
    <a class="ds-demo-menu__link" href="#">Edit</a>
    <a class="ds-demo-menu__link" href="#">History</a>
    <hr class="ds-demo-menu__divider">
    <a class="ds-demo-menu__link" href="#">Share</a>
</nav>

    </ol-popover>
    <ol-popover placement="top-center" aria-label="Actions">
        <ol-button slot="trigger">
            top-center
        </ol-button>
            <nav class="ds-demo-menu"
     style="min-width: 160px"
     aria-label="Book">
    <a class="ds-demo-menu__link" href="#">Edit</a>
    <a class="ds-demo-menu__link" href="#">History</a>
    <hr class="ds-demo-menu__divider">
    <a class="ds-demo-menu__link" href="#">Share</a>
</nav>

    </ol-popover>

Block outside clicks

By default the click that dismisses a popover also lands on whatever is underneath. With block-outside-clicks a transparent backdrop swallows it, so opening over dense links is safe. Try closing each popover by clicking a link beside it.

<ol-popover block-outside-clicks>
  <ol-button slot="trigger">Options</ol-button>
  <ul>...</ul>
</ol-popover>
API reference
Properties
PropertyAttributeTypeDefaultDescription
open open Boolean false Whether the popover is currently open
placement placement String 'bottom-start' Preferred placement relative to the trigger. Format: "{side}-{align}" where side is "top" or "bottom" and align is "start", "center", or "end". Default: "bottom-start" — a panel is usually wider than the control that opens it, and aligning their leading edges keeps it under the trigger instead of straddling it.
anchor anchor String '' Selector for an ancestor to position against instead of the trigger, e.g. the whole split button when only its caret opens the popover. Falls back to the trigger when nothing matches. The panel is never narrower than what it is anchored to, so a short menu under a wide button fills the button's width instead of floating beside it.
anchorElement — Element — Property-only alternative to `anchor` for a positioning element `closest()` cannot reach, such as a host across a shadow boundary. Wins over `anchor` when both are set.
offset offset Number 4 Gap in px between trigger and popover (default: 4)
autoClose auto-close Boolean true Whether outside clicks close the popover. Escape always closes for accessibility. Default: true
blockOutsideClicks block-outside-clicks Boolean false Swallow the click that dismisses the popover instead of letting it reach what is underneath. Light dismiss (like native `popover=auto`) lets it through, which over a page of links also follows the link under the pointer. A transparent backdrop takes the hit, so hover and wheel under the panel are blocked too, like a native menu. The popover stays non-modal for assistive tech. Default: false
Events
EventTypeDescription
ol-popover-open CustomEvent Fired when the popover opens. detail: { placement: String }
ol-popover-close CustomEvent Cancelable. Fired when the popover requests to close. Call `preventDefault()` to keep it open. Note: the swipe-dismiss close fires after the gesture completes and is not cancelable. detail: { reason: 'escape' | 'outside-click' | 'swipe' | 'trigger' | 'tab' }
Slots
SlotDescription
trigger The trigger element (button, icon, etc.)
(default) Default slot for popover content
CSS custom properties
PropertyDefaultDescription
--ol-popover-content-max-width — Width cap for slotted content; the panel is `width: auto` and never shrinks, so long content clamps itself. Set it on the wrapping element; the tray clears it for its full-bleed sheet.
--ol-popover-content-max-height — Height cap for slotted content, so an inner scroll region resolves. As above; the tray swaps in its own cap.

Select Popover <ol-select-popover>

Avoid: For a single choice use Options Popover; for four or fewer choices use Segmented Control.

Default

Above 8 items a filter input renders automatically. Selected items move to a SELECTED group on next open.

Language

Selected: (none)

<ol-select-popover
  label="Language"
  placeholder="Filter languages…"
  unselected-heading="LANGUAGES"
  items='[{"value":"en","label":"English"}, ...]'>
  <ol-button slot="trigger">Language</ol-button>
</ol-select-popover>

Small list, no search

At 8 items or fewer the filter input is hidden. Override with search-threshold — 0 always shows it, a large value always hides it.

Layout
<ol-select-popover id="demo-select-layout" label="Layout" unselected-heading="LAYOUTS" aria-label="Layout filter">
    <ol-button slot="trigger">
        Layout
    </ol-button>
</ol-select-popover>
<script>
    document.getElementById('demo-select-layout').items = [
        { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }, { value: 'compact', label: 'Compact' }
    ];
</script>

Custom trigger

Any element with slot="trigger" works; the component handles open/close and ARIA wiring.

Pick a genre
<ol-select-popover id="demo-select-custom" label="Genre" unselected-heading="GENRES" aria-label="Genre filter">
    <a slot="trigger"
       href="#"
       role="button"
       class="ds-demo-link-trigger"
       onclick="event.preventDefault();">Pick a genre</a>
</ol-select-popover>
<script>
    document.getElementById('demo-select-custom').items = [
        { value: 'fic', label: 'Fiction' }, { value: 'nf', label: 'Non-fiction' },
        { value: 'sci', label: 'Science' }, { value: 'his', label: 'History' },
        { value: 'bio', label: 'Biography' }
    ];
</script>

Paired with a chip group

Selections render as chips next to the trigger. Clicking a chip's close icon removes that selection from the popover. Keep the chip group derived from the popover rather than giving it its own state, so there is one place to read the current selection.

Language
<div class="ds-demo-row">
    <ol-select-popover id="demo-select-chips" label="Language" placeholder="Filter languages…" unselected-heading="LANGUAGES" aria-label="Language filter">
        <ol-button slot="trigger">
            Language
        </ol-button>
    </ol-select-popover>
    <ol-chip-group id="demo-select-chips-group" gap="small">
    </ol-chip-group>
</div>
<script>
    (function () {
        const popover = document.getElementById('demo-select-chips');
        const chipGroup = document.getElementById('demo-select-chips-group');
        const items = [
            { value: 'en', label: 'English' }, { value: 'es', label: 'Spanish' },
            { value: 'fr', label: 'French' }, { value: 'de', label: 'German' },
            { value: 'it', label: 'Italian' }, { value: 'pt', label: 'Portuguese' },
            { value: 'zh', label: 'Chinese' }, { value: 'ja', label: 'Japanese' },
            { value: 'ru', label: 'Russian' }
        ];
        popover.items = items;
        const labelByValue = Object.fromEntries(items.map((item) => [item.value, item.label]));

        function renderChips(selected) {
            chipGroup.innerHTML = '';
            selected.forEach((value) => {
                const chip = document.createElement('ol-chip');
                chip.setAttribute('selected', '');
                chip.setAttribute('size', 'small');
                chip.dataset.value = value;
                chip.textContent = labelByValue[value] || value;
                chipGroup.appendChild(chip);
            });
        }

        popover.addEventListener('ol-select-popover-change', (e) => renderChips(e.detail.selected));
        // A selected chip's close icon fires ol-chip-select with detail.selected false.
        chipGroup.addEventListener('ol-chip-select', (e) => {
            if (e.detail.selected !== false) return;
            const next = (popover.selected || []).filter((value) => value !== e.target.dataset.value);
            popover.selected = next;
            renderChips(next);
        });
    })();
</script>
API reference
Properties
PropertyAttributeTypeDefaultDescription
formAssociatedValue — — — Override point. The value(s) to submit with the form.
items items Array [] List of `{ value, label }` objects. Settable as JSON attribute (`items='[{"value":"en","label":"English"}]'`) or property.
selected selected Array [] Array of selected `value`s. Reflects to attribute as JSON.
label label String '' Default trigger button text (e.g. "Language").
searchThreshold search-threshold Number FILTER_THRESHOLD Show the filter input when `items.length` exceeds this value. Default `8`. Use `0` to always show, a large number (e.g. `999`) to never show. Attribute: `search-threshold`.
placeholder placeholder String 'Filter…' Filter input placeholder.
unselectedHeading unselected-heading String '' Heading for the list when nothing is selected (e.g. "LANGUAGES"). Falls back to `suggestionsHeading` if unset.
selectedHeading selected-heading String 'SELECTED' Heading for the SELECTED group (default "SELECTED").
suggestionsHeading suggestions-heading String 'SUGGESTIONS' Heading for the suggestions group when ≥1 item is selected (default "SUGGESTIONS").
clearLabel clear-label String 'Clear selections' Label for the clear-selections button (default "Clear selections").
noMatchesLabel no-matches-label String 'No matches' Empty-state text when the filter has no matches (default "No matches").
loadingLabel loading-label String 'Loading…' Text shown beside the spinner while `loading` is set (default "Loading…").
name name string — Form field name. When set, each selected value submits with the enclosing `<form>` as a repeated `name` entry (see FormAssociatedMixin).
isDisabled — — — Whether the control is disabled from either source: its own `disabled` property or an ancestor `<fieldset disabled>`. Use this — not `disabled` — to gate interaction and to set `?disabled` on inner controls.
Events
EventTypeDescription
ol-select-popover-clear CustomEvent Fires when the clear-selections button is clicked. A change event also fires with the cleared selection.
ol-select-popover-change CustomEvent Fires when the selection changes. detail: { selected: String[], added: String|null, removed: String|null }
ol-select-popover-request-open — Cancelable; fires when the patron activates the trigger on a closed panel. detail: { focusFirst: Boolean }. Calling preventDefault() defers the open: the panel stays shut until the listener calls show(). For hosts that load items on demand — the panel then opens once, at its final size, instead of resizing and re-sorting under the pointer. A host that defers owns any busy affordance, and should keep the wait short enough not to need one.
Slots
SlotDescription
trigger Optional custom trigger element. When omitted, a default `<ol-button>` is injected, labelled by the current selection: `label` when nothing is picked, the single item's own label when one is, and `label (n)` beyond that. It also carries ol-button's `selected` tint while a selection is active, and its disclosure chevron comes from ol-button automatically. A custom trigger owns its own label and state.

Options Popover <ol-options-popover>

Avoid: Multiple selections belong in a Select Popover.

Single-select menu

With a value and label per item the rows render as a tidy single-select menu — a sort order or layout switcher. The heading defaults to the uppercased label; override with heading.

Selected: relevance

<ol-options-popover
  label="Sort by"
  heading="SORT ORDER"
  selected="relevance"
  items='[{"value":"relevance","label":"Relevance"}, ...]'>
</ol-options-popover>

Descriptions and counts

Optional description and count per item. Both render only when present, so plain and rich rows can mix in one list.

<ol-options-popover id="demo-options-genre" label="Genre" selected="fiction" aria-label="Genre filter">
</ol-options-popover>
<script>
    document.getElementById('demo-options-genre').items = [
        { value: 'fiction', label: 'Fiction', description: 'Novels and short stories', count: '1,024' },
        { value: 'nonfiction', label: 'Nonfiction', description: 'Fact-based works and essays', count: '892' },
        { value: 'poetry', label: 'Poetry', description: 'Verse and collections', count: '213' },
        { value: 'reference', label: 'Reference', description: 'Dictionaries and encyclopedias', count: '156' }
    ];
</script>

Searchable field

Past search-threshold items (8 by default, shared with ol-select-popover) a filter input appears; arrows then move focus without changing the value, and Enter picks the only match. show-selection puts the chosen label on the trigger, for use as a form field.

<ol-options-popover
  name="language"
  label="Choose a language"
  placeholder="Filter languages"
  show-selection
  items='[{"value":"eng","label":"English"}, ...]'>
</ol-options-popover>

Custom trigger

Supply your own trigger when the default button doesn't fit. The component still handles open/close, ARIA wiring, and keyboard navigation (arrows, Home/End, Escape).

<ol-options-popover id="demo-options-custom" label="Sort by" selected="newest" aria-label="Sort order">
    <button slot="trigger" class="demo-btn">Sort results</button>
</ol-options-popover>
<script>
    document.getElementById('demo-options-custom').items = [
        { value: 'relevance', label: 'Relevance' },
        { value: 'newest', label: 'Date added (newest)' },
        { value: 'oldest', label: 'Date added (oldest)' },
        { value: 'title', label: 'Title (A–Z)' }
    ];
</script>
API reference
Properties
PropertyAttributeTypeDefaultDescription
formAssociatedValue — — — Override point. The value(s) to submit with the form.
items items Array [] List of `{ value, label, description?, count?, nested? }` objects. Settable as JSON attribute or property. `nested: true` indents the option to show it's a subset of the option above it.
selected selected String '' Currently selected `value`, or empty string for no selection. Reflects to attribute.
label label String '' Default trigger button text (e.g. "Availability").
heading heading String '' Heading shown above the options list (default: uppercased `label`).
searchThreshold search-threshold Number FILTER_THRESHOLD Show the filter input when `items.length` exceeds this value. Default `FILTER_THRESHOLD`. Attribute: `search-threshold`.
placeholder placeholder String 'Filter…' Filter input placeholder.
noMatchesLabel no-matches-label String 'No matches' Empty-state text when the filter has no matches (default "No matches"). Attribute: `no-matches-label`.
showSelection show-selection Boolean false The default trigger shows the selected option's label, falling back to `label` when nothing is picked. For use as a form field; filters leave it off and show the choice elsewhere. Attribute: `show-selection`.
name name string — Form field name. When set, the selected value submits with the enclosing `<form>` (see FormAssociatedMixin).
isDisabled — — — Whether the control is disabled from either source: its own `disabled` property or an ancestor `<fieldset disabled>`. Use this — not `disabled` — to gate interaction and to set `?disabled` on inner controls.
Events
EventTypeDescription
ol-options-popover-change CustomEvent Fires when the selection changes. detail: { selected: String }
Slots
SlotDescription
trigger Optional custom trigger element. When omitted, an `<ol-button>` showing `label` is injected (see _createDefaultTrigger); its disclosure chevron comes from ol-button automatically.

Dialog <ol-dialog>

Avoid: For a task with its own scrolling content, use Drawer. For a passive message, use Toast or Banner.

Confirmation

For a yes/no question, await olConfirm() — the async replacement for window.confirm(). It builds an alert <ol-dialog> with Cancel and a confirm button, removes it once closed, and resolves true or false. Every label defaults to English, so always pass translated strings.

Delete list… Remove from list… Return book…

Resolved: (none)

import { olConfirm } from '../../../components/lit/alert-dialog.js';

// true only for the confirm button; Escape, the backdrop, and the close button count as cancel
if (await olConfirm({
    title: i18n.deleteListTitle,
    message: i18n.deleteListBody,   // strings are always text, never HTML
    confirmLabel: i18n.deleteList,  // name the action, not OK
    cancelLabel: i18n.cancel,
    labelClose: i18n.close,
    destructive: true,              // tone='danger' button, initial focus on Cancel
})) {
    await deleteList(listKey);
}

// For markup, write a <template> in the page template, where $_() extraction works:
// <template id=remove-seed-message>$:_('Remove <strong>%(title)s</strong> from this list?', title=websafe(title))</template>
await olConfirm({ title: i18n.removeTitle, message: document.querySelector('#remove-seed-message') });

Alert

When the reader must acknowledge something before continuing, await olAlert(). It has a single button and resolves once closed. Anything they can safely miss is a Toast.

Merge authors
import { olAlert } from '../../../components/lit/alert-dialog.js';

await olAlert({
    title: i18n.noPrimaryTitle,
    message: i18n.noPrimaryBody,
    okLabel: i18n.ok,
});

Width presets

small is 400px, medium 550px (default), large 800px. Never exceeds 90vw, so presets stay responsive. Override one instance with the --ol-dialog-width-* custom properties.

Small Medium Large

Using the medium preset. Resize the window to see it stay within 90vw.

<ol-button data-width="small" variant="secondary">
    Small
</ol-button>
<ol-button data-width="medium" variant="secondary">
    Medium
</ol-button>
<ol-button data-width="large" variant="secondary">
    Large
</ol-button>
<ol-dialog id="demo-dialog-width" label="Width demo">
    <p class="ds-demo-dialog-body">
        Using the <strong id="demo-dialog-width-label">medium</strong> preset. Resize the window to see it stay within 90vw.
    </p>
</ol-dialog>
<script>
    (function () {
        const dialog = document.getElementById('demo-dialog-width');
        const label = document.getElementById('demo-dialog-width-label');
        document.querySelectorAll('[data-width]').forEach((button) => {
            button.addEventListener('click', () => {
                dialog.width = button.dataset.width;
                label.textContent = button.dataset.width;
                dialog.open = true;
            });
        });
    })();
</script>

Form dialog

Once a dialog collects input, compose <ol-dialog> yourself — olConfirm() only asks yes/no. icon puts a decorative glyph before the title, matching the control the dialog was opened from. close(value) records which button closed it, like native <dialog>; read it from detail.returnValue on ol-close (cancelable, so you can validate and keep the dialog open) or ol-after-close. Escape and the backdrop close with an empty returnValue.

Add a note…

Closed with: (none)

<ol-dialog label="Add a note" icon="sticky-note" width="medium">
  <textarea>…</textarea>
  <div slot="footer">
    <ol-button onclick="dialog.close('cancel')">Cancel</ol-button>
    <ol-button variant="primary" onclick="dialog.close('save')">Save note</ol-button>
  </div>
</ol-dialog>

dialog.addEventListener('ol-close', (event) => {
  if (event.detail.returnValue === 'save' && !textarea.value.trim()) event.preventDefault();
});

Command palette / search modal

placement="top" plus without-header, fullscreen-on-mobile, and --ol-dialog-padding: 0 slots a search bar into the header region and lets results grow downward while the top edge stays put. This is the site header's search modal. Focus the input on ol-after-open.

Search…
  • The Left Hand of Darkness — Ursula K. Le Guin
  • A Wizard of Earthsea — Ursula K. Le Guin
  • The Dispossessed — Ursula K. Le Guin
<ol-dialog
 placement="top"
  without-header
  fullscreen-on-mobile
  label="Search the catalog"
  style="--ol-dialog-padding: 0">
  <div slot="header"><input type="search" /></div>
  <ul>…results…</ul>
</ol-dialog>
API reference
Properties
PropertyAttributeTypeDefaultDescription
open open Boolean false Whether the dialog is open.
label label String '' Title shown in the default header. Also used as the accessible name when `withoutHeader` is true.
icon icon String '' Name of an `ol-icon` to show before the title in the default header. Decorative: the title already names the dialog.
withoutHeader without-header Boolean false Hide the default header (title + close button). The `header` slot still works.
alert alert Boolean false Announce as `role="alertdialog"`, with the body as the accessible description. For confirmations and alerts that interrupt the reader; see `olConfirm()` in alert-dialog.js.
labelClose label-close String 'Close dialog' Translated accessible name for the default close button. Attribute: `label-close`. Default "Close dialog".
returnValue — String '' Set by `close(returnValue)`; empty when closed by Escape, the backdrop, or the close button. Reset each time it opens.
width width 'small' | 'medium' | 'large' 'medium' Width preset: `'small'` (400px), `'medium'` (550px, default), or `'large'` (800px). Override per-instance via `--ol-dialog-width-*` host CSS variables.
closeOnBackdropClick close-on-backdrop-click Boolean true Whether clicking the backdrop closes the dialog. Default `true`. Attribute: `close-on-backdrop-click`.
closeOnEscape close-on-escape Boolean true Whether pressing Escape closes the dialog. Default `true`. Attribute: `close-on-escape`.
fullscreenOnMobile fullscreen-on-mobile Boolean false At viewports ≤767px, render edge-to-edge (full viewport, no border-radius). Attribute: `fullscreen-on-mobile`.
placement placement 'center' | 'top' 'center' `'center'` (default) keeps the dialog vertically centered like a normal modal. `'top'` anchors it a fixed distance from the top of the viewport so the top edge stays put as content grows or shrinks (command-palette / search-modal pattern).
Events
EventTypeDescription
ol-open CustomEvent Fires when the dialog starts opening.
ol-after-open CustomEvent Fires after the open animation completes.
ol-after-close CustomEvent Fires after the close animation completes, with the same `detail.returnValue`.
ol-close — Fires when the dialog starts closing. Cancelable — calling `event.preventDefault()` keeps the dialog open. `detail.returnValue` says how it was closed.
Slots
SlotDescription
(default) Default slot for the dialog body.
header Optional custom header. When filled, replaces the default title + close-button row entirely. Useful for search bars, custom toolbars, etc.
footer Slot for action buttons. Footer region is hidden when empty.
CSS custom properties
PropertyDefaultDescription
--ol-dialog-padding — Padding around body and footer regions. Set to `0` for edge-to-edge content (e.g. when slotting a search bar or filter row that owns its own padding).
--ol-dialog-max-height 85vh Maximum dialog height; the body scrolls past it. Slotted content can read it to size itself to fit (e.g. an enlarged image).
--ol-dialog-border-radius — Corner radius (ignored in fullscreen mode).
--ol-dialog-backdrop-color — Backdrop color.
--ol-dialog-backdrop-blur — Blur radius applied to the page behind the backdrop. Set to `0` to dim without blurring.
--ol-dialog-animation-duration — Open/close animation duration.
--ol-dialog-top-offset — Distance from viewport top when `placement="top"`. Default `clamp(40px, 8vh, 96px)`.

Drawer <ol-drawer>

Avoid: A centered interruption is a Dialog. A panel anchored to its trigger is a Popover.

Navigation drawer

Slides in from the right edge. Focus is trapped inside, background scroll is locked, and Escape or a backdrop click dismisses it. On touch devices you can also swipe it away.

Open menu…
  • My Books
  • My Profile
  • Settings
  • Subjects
  • Trending
  • Library Explorer
<ol-drawer label="Menu" placement="end">
  <nav>…</nav>
</ol-drawer>

<script>
  document.querySelector('ol-drawer').open = true;
</script>

Start placement

placement="start" slides from the left (right in RTL). Swipe-to-dismiss follows the placement, so the gesture always moves the panel off its own edge.

Open filters…
<ol-drawer label="Filters" placement="start">…</ol-drawer>

Custom width

--ol-drawer-width sets the panel width; it never exceeds the viewport. --ol-drawer-enter-duration and --ol-drawer-exit-duration tune the slide.

Open wide drawer…
  • 420px wide instead of the 300px default.
<ol-drawer label="Wide panel" style="--ol-drawer-width: 420px">…</ol-drawer>
API reference
Properties
PropertyAttributeTypeDefaultDescription
open open Boolean false Whether the drawer is currently visible.
placement placement 'start' | 'end' 'end' Which edge the drawer slides from: `'start'` (left in LTR) or `'end'` (right in LTR). Default: `'end'`
label label String '' Accessible label for the drawer dialog.
closeOnScrimClick close-on-scrim-click Boolean true Whether clicking the scrim closes the drawer. Default `true`. Attribute: `close-on-scrim-click`.
closeOnEscape close-on-escape Boolean true Whether Escape closes the drawer. Default `true`. Attribute: `close-on-escape`.
Events
EventTypeDescription
ol-drawer-show CustomEvent Fired when the drawer begins opening.
ol-drawer-after-show CustomEvent Fired after the enter transition completes.
ol-drawer-after-hide CustomEvent Fired after the exit transition completes.
ol-drawer-hide — Fired when a close is requested, before the exit transition. Cancelable — `event.preventDefault()` keeps the drawer open. detail: { reason: 'escape' | 'scrim' | 'swipe' | 'programmatic' }
Slots
SlotDescription
(default) Default slot for drawer content.
CSS custom properties
PropertyDefaultDescription
--ol-drawer-width 300px Width of the drawer panel.
--ol-drawer-scrim-color var(--overlay-backdrop-color) Scrim color.
--ol-drawer-scrim-blur var(--overlay-backdrop-blur) Blur radius applied to the page behind the scrim. The drawer is modal, so it blurs; see docs/frontend/design.md#blur-follows-modality-not-viewport-width.
--ol-drawer-enter-duration var(--duration-slower) Slide-in duration.
--ol-drawer-exit-duration var(--duration-slow) Slide-out duration.
--ol-drawer-scroll-padding 0 Inset kept clear when Tab scrolls a focused element into view, for drawers with a sticky header or footer.

Feedback

Toast <ol-toast>

Avoid: Anything the reader must act on belongs in a Dialog or a Banner.

Types

Errors are announced assertively (role="alert").

showToast(i18nStrings.savedToReadingLog);
showToast(i18nStrings.subjectsUpdated, { type: 'success' });
showToast(i18nStrings.listUpdateFailed, { type: 'error' });

Description and rich content

description adds a secondary line. For rich content, author the markup in an inert <template> in the page template — where $_() extraction works — and pass that element to showToast(); its content is cloned in. Nothing is ever parsed from a runtime string as HTML.

Could not save. Get help
showToast(i18nStrings.relaunchToUpdate, { description: 'v1.11187.4', persistent: true });

<!-- Rich content: write the HTML in the page template… -->
<template id="save-error-toast">
  $_("Could not save.") <a href="/help">$_("Get help")</a>
</template>

// …and pass the template element to showToast()
showToast(document.querySelector('#save-error-toast'), { type: 'error', persistent: true });

Imperative use

showToast() stacks toasts in a shared fixed <ol-toast-region> at the bottom-center of the viewport. New toasts slide up from below; older ones move up to make room. They dismiss after timeout ms (default 4000) unless persistent. Hovering or focusing the list pauses every timer. The message is set as an attribute — always text, never HTML — and should already be translated.

import { showToast } from './OlToastRegion.js';

showToast(i18nStrings.updateSuccess, { type: 'success' });
showToast(i18nStrings.failedSubmitCheckIn, { type: 'error', persistent: true });

Close event. Fires ol-toast-close once when closing begins, with the reason in event.detail.reason — "timeout", "close-button", or "programmatic".

API reference
Properties
PropertyAttributeTypeDefaultDescription
type type "info" | "success" | "error" 'info' Default: "info". Errors use role="alert" / assertive announcements.
message message String '' The (already translated) message text.
description description String '' Optional secondary line, rendered smaller and muted.
persistent persistent Boolean false Toast stays until explicitly closed (no timer).
timeout timeout Number 4000 Milliseconds before auto-dismiss. Default: 4000.
labelClose label-close String 'Close' Aria label for the close button (default: "Close")
Events
EventTypeDescription
ol-toast-resize CustomEvent
ol-toast-close CustomEvent Fired once when the toast begins closing. detail: { reason: "timeout" | "close-button" | "programmatic" }
Slots
SlotDescription
(default) Rich message content (links, custom markup). Overrides message/description.

Message

Avoid: Inline, next to the thing it describes. For page-level notices use Banner.

A plain CSS component — no JS, no custom element. Use it for status that belongs next to the thing it describes, inline in the page flow. For page-level announcements use Banner; for transient confirmations use Toast.

Variants

The bare class is styled the same as --info.

Your changes are visible to other librarians immediately.
Edition merged successfully.
This record has unresolved import errors.
Could not save — the record was modified by someone else.
<div class="ol-message ol-message--info">Your changes are visible to other librarians immediately.</div>
<div class="ol-message ol-message--success">Edition merged successfully.</div>
<div class="ol-message ol-message--warning">This record has unresolved import errors.</div>
<div class="ol-message ol-message--error">Could not save — the record was modified by someone else.</div>

Source. static/css/components/ol-message.css. Note it currently hardcodes its hsl() values rather than using the status tokens under Foundations → Colors — worth migrating when next touched.

Related. flash-messages.css styles the server-rendered flash region at the top of the page; it isn't reusable markup, but it is where post-redirect confirmations land.

Scorecard <ol-scorecard>

Avoid: Purpose-built for the book-quality score; don't repurpose it as a generic gauge.

Displays a metadata quality scorecard as a tabbed set of sections, each listing the checks that make up its score. Data is passed as a JSON results attribute or property; labels are individually translatable.

Collapsed (default)

Shows a small ring proportional to the overall percentage. Click it to expand.

<ol-scorecard
  results='{"name":"Edition Scorecard","score":115,"maxScore":225,"sections":[...]}'>
</ol-scorecard>

Expanded

The expanded attribute renders the full tabbed UI directly. Clicking the header collapses it again.

<ol-scorecard
  expanded
  results='{"name":"Edition Scorecard","score":115,"maxScore":225,"sections":[...]}'>
</ol-scorecard>
API reference
Properties
PropertyAttributeTypeDefaultDescription
results results Object — The scorecard data: `{ name, score, maxScore, sections: [{ name, score, maxScore, checks: [{ description, details, score, passing }] }] }`. Settable as a JSON attribute (`results='{"score":10,...}'`) or property.
expanded expanded Boolean false Whether the full tabbed UI is shown instead of the collapsed badge. Default `false`. Presence of the attribute expands it.
labelTotal label-total String 'Total' Label for the non-interactive "Total" gauge (default: "Total")
labelFailingChecks label-failing-checks String 'Failing Checks ({count})' Failing checks section heading template, use `{count}` (default: "Failing Checks ({count})")
labelPassingChecks label-passing-checks String 'Passing Checks ({count})' Passing checks section heading template, use `{count}` (default: "Passing Checks ({count})")
labelPoints label-points String '{score} points' Check point-value template, use `{score}` (default: "{score} points")
labelExpand label-expand String '{name}: {percentage}%. Click to expand.' Accessible label for the collapsed badge button, use `{name}`, `{percentage}` (default: "{name}: {percentage}%. Click to expand.")
labelCollapse label-collapse String 'Collapse' Accessible label for the header collapse button (default: "Collapse")
outdated outdated Boolean false Whether the solr record is stale relative to the database. Default `false`. Presence of the attribute shows a warning banner at the top of the expanded card; no banner is shown otherwise.
labelOutdated label-outdated String 'This record has been edited and is not yet reflected in Solr. It should update in a minute or so.' Warning banner text shown when outdated (default: "This record has been edited and is not yet reflected in Solr. It should update in a minute or so.")

Content

Read More <ol-read-more>

Avoid: Only for prose. Don't hide controls or lists behind it.

Default

Content collapses to 80px, the default max-height.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.

<ol-read-more>
            <p>    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.
</p>
        </ol-read-more>

Custom collapsed height

Any max-height other than the default needs a matching min-height on the host — max-height plus 41px (or plus 27px with label-size="small") — so the page doesn't shift as the component measures itself.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.

<ol-read-more max-height="160px" style="min-height: 201px">
            <p>    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.
</p>
        </ol-read-more>

Custom button text

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.

<ol-read-more more-text="Show more" less-text="Show less">
            <p>    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.
</p>
        </ol-read-more>

Custom background color

Match the gradient fade to your container's background.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.

<ol-read-more background-color="var(--blue-50)">
            <p>    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.
</p>
        </ol-read-more>

Small label size

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.

<ol-read-more label-size="small">
            <p>    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt neque porro quisquam est.
</p>
        </ol-read-more>

Content that fits

No toggle renders unless the content runs at least four lines past the limit — hiding less than that isn't worth a control.

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.

<ol-read-more>
            <p>    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.
</p>
        </ol-read-more>
API reference
Properties
PropertyAttributeTypeDefaultDescription
maxHeight max-height String '80px' Collapsed height of the content before truncating (default: "80px")
moreText more-text String 'Read More' Label for the expand toggle (default: "Read more")
lessText less-text String 'Read Less' Label for the collapse toggle (default: "Read less")
backgroundColor background-color String — Background color for the gradient fade (default: white)
labelSize label-size "medium" | "small" 'medium' Size of the toggle button text: "medium" (default) or "small" (12px)
Slots
SlotDescription
(default) The collapsible content
CSS custom properties
PropertyDefaultDescription
--ol-readmore-link-color var(--color-link) Color of the more/less toggle button
--ol-readmore-gradient-color white Solid color the fade gradient blends toward (match the surrounding background)
--ol-readmore-gradient-color-transparent rgba(255, 255, 255, 0) Transparent end of the fade gradient
CSS parts
PartDescription
toggle-btn The toggle button element (targets both "more" and "less" buttons)

Markdown Editor <ol-markdown-editor>

Avoid: For a plain text field use <textarea>; this is for Markdown bodies only.

Default

Point target-id at an existing textarea. The editor reads its initial content from that textarea and syncs changes back to it, so the textarea stays the value the form submits.

<textarea id="my-input">
  **Hello** world
</textarea>
<ol-markdown-editor
 target-id="my-input">
</ol-markdown-editor>

Mixed Markdown and HTML

enable-html-block exposes the HTML block button. HTML blocks render with a preview and an Edit button for the source.

<ol-markdown-editor
 target-id="my-input"
  enable-html-block>
</ol-markdown-editor>

Code blocks and inline code

enable-code exposes the inline-code and code-block buttons. Off by default because only the wiki page renderer is guaranteed to render fenced code blocks.

<ol-markdown-editor
 target-id="my-input"
  enable-code>
</ol-markdown-editor>

Change event

Fires ol-markdown-editor-change on every change, with the raw Markdown in event.detail.value.


    
<ol-markdown-editor
 target-id="my-input"
  height="100px">
</ol-markdown-editor>

<script>
  editor.addEventListener('ol-markdown-editor-change', (e) => {
    console.log(e.detail.value);
  });
</script>
API reference
Events
EventTypeDescription
ol-markdown-editor-change CustomEvent

Icon <ol-icon>

Avoid: Only for icons from the set; don't use it to embed arbitrary SVG.

<ol-icon> is the client-side way to draw a glyph, for markup a component builds at runtime. Server-rendered markup should use the icon() macro instead, and a glyph inside another component's shadow root has to be inlined — <use> is unreliable there. The Icons section has the gallery, all three techniques side by side, and the <ol-icon> API.

Same sprite and same CSS as the macro; size is sm (16px), md (20px, default) or lg (24px).

<ol-icon name="search" size="sm"></ol-icon>
<ol-icon name="search"></ol-icon>
<ol-icon name="search" size="lg"></ol-icon>

Books

Book Cover <ol-book-cover>

A book cover at a fixed 2:3 ratio, with somewhere to float a control over it. It knows nothing about shelves, availability or search — give it an image URL and a title. When there is no artwork it draws a title/author panel instead, so a shelf of covers keeps its rhythm rather than opening a hole. The overlay slot is where the save button goes; the component owns the corner so a consumer never positions it.

With artwork

The common case. The image is lazy, and its alt text is the title and author — a cover is a picture of the book, not decoration.

<ol-book-cover src="https://covers.openlibrary.org/b/id/12622062-M.jpg" book-title="The Secret Garden" authors="Frances Hodgson Burnett" href="/works/OL69612W" style="width: 128px;">
</ol-book-cover>

No artwork

The generated cover. The title clamps to four lines and the author sits under it in small caps. At size="small" the author is dropped — a 72px row cover has no room for it.

<ol-book-cover book-title="The Fellowship of the Ring" authors="J.R.R. Tolkien" style="width: 128px;">
</ol-book-cover>
<ol-book-cover size="small" book-title="The Fellowship of the Ring" authors="J.R.R. Tolkien" style="width: 72px;">
</ol-book-cover>

Hover card

A pointer gets the title, year and author over the cover. ol-tooltip arms on the same media query cover-card layouts hide their caption with, so exactly one of the two ever shows.

<ol-book-cover src="https://covers.openlibrary.org/b/id/12622062-M.jpg" book-title="The Secret Garden" authors="Frances Hodgson Burnett" year="1911" href="/works/OL69612W" style="width: 128px;">
</ol-book-cover>

Overlay

Anything slotted into overlay takes the top-right corner, over the artwork and outside the link — so hovering the control is not hovering the cover, and clicking it does not navigate.

<ol-book-cover src="https://covers.openlibrary.org/b/id/12622062-M.jpg"
               book-title="The Secret Garden"
               authors="Frances Hodgson Burnett"
               href="/works/OL69612W">
  <ol-shelf-button slot="overlay" variant="icon"
                   user-key="/people/you"
                   work-key="/works/OL69612W"
                   book-title="The Secret Garden">
  </ol-shelf-button>
</ol-book-cover>
API reference
Properties
PropertyAttributeTypeDefaultDescription
src src String '' Cover image URL; empty draws the generated blank cover
bookTitle book-title String '' The book's title. Named `book-title` because a `title` attribute would draw a native browser tooltip over the whole host
authors authors String '' Author names, already joined for display
year year String '' First publication year, shown in the hover card
href href String '' Link target; empty renders the cover unlinked
size size String 'medium' "medium" (default) or "small"; small drops the author from the blank cover, which has no room for it
deferred deferred Boolean false Holds the artwork back until removed (ol-carousel removes it near view)
labels labels Object {} Translated strings, merged over DEFAULT_LABELS
Events
EventTypeDescription
ol-book-cover-click CustomEvent The cover link was clicked. detail: { href }
Slots
SlotDescription
overlay Pinned to the cover's top-right corner, over the artwork

Shelf Button <ol-shelf-button>

The control that puts a book on a reading-log shelf, in the three shapes the site needs. All open the same Shelf Actions popover; the split variant adds a main half that toggles Want to Read without opening anything. Signed out, every shape sends the visitor to log in with the intent remembered.

Split

For a row, where there is width for a label. The main half toggles the shown shelf; the chevron opens the popover. Signed in, this demo opens on your real state for The Secret Garden and saves every change to your account.

<ol-shelf-button
  user-key="/people/you"
  work-key="/works/OL69612W"
  book-title="The Secret Garden">
</ol-shelf-button>

Icon

For a cover, where there is not. An outlined bookmark until the book is on a shelf, then that shelf's own glyph in blue (the bookmark filled for Want to Read) — the circle stays white so it reads against any artwork. Same book as above, so the two stay in step.

<ol-book-cover src="https://covers.openlibrary.org/b/id/12622062-M.jpg"
               book-title="The Secret Garden"
               authors="Frances Hodgson Burnett"
               href="/works/OL69612W">
  <ol-shelf-button slot="overlay" variant="icon"
                   user-key="/people/you"
                   work-key="/works/OL69612W"
                   book-title="The Secret Garden">
  </ol-shelf-button>
</ol-book-cover>

Outline

The icon shape's glyph in the split button's bordered square, for a row with room for a button but not a label — the search modal's results. Same book again.

<ol-shelf-button variant="outline"
  user-key="/people/you"
  work-key="/works/OL69612W"
  book-title="The Secret Garden">
</ol-shelf-button>

Signed in only for the popover. Without user-key no popover is built at all; the trigger stands alone and its click goes to login. The demos above pass your key when you are signed in, so they write to your real reading log.

On the site. Every button comes from one server helper, shelf_button_for(doc, variant, reading_states, async_load), which finds the work, edition and title in a Solr or Infogami doc and renders my_books/shelf_button.html.jinja. Rows and the book page get the split shape with the reader's state from get_reading_states() already on it. Carousel cards ask for async_load and get the icon shape with no state at all, because their HTML is cached or fetched lazily and so cannot carry one reader's state. An author, or an edition with no work, gets the same control with lists-only. book-state.js is the surface behind every button on a page: it hands out the translated labels and the reader's key, fetches the missing state in one batch from /partials/ReadingState.json, and writes every ol-book-state-change onto each button for that work — so the same book in a carousel and in a search row stay in step.

Unknown state. A surface that fetches state after rendering its buttons — carousels, which book-state.js hydrates in one batch, and the search modal, which asks only once the reader reaches for a button — sets pending until it knows. The button dims and drops its clicks, and the popover holds its shelf and rating rows: posting the shelf a book is already on removes it, so acting on a guess could undo a save.

API reference
Properties
PropertyAttributeTypeDefaultDescription
variant variant "split" | "icon" | "outline" 'split' Default: "split"
workKey work-key String '' "/works/OL…W", the book this acts on. With `lists-only`, the seed instead: "/authors/OL…A" or "/books/OL…M"
editionKey edition-key String '' "OL…M", recorded with the shelf change when known
bookTitle book-title String '' Used in the accessible labels. Named `book-title` because a `title` attribute would draw a native browser tooltip
shelf shelf Number — Current shelf id (1–4), or null when on none. Reflected, so the page's CSS can tell a saved book from an unsaved one (`ol-shelf-button[shelf]`) — a carousel keeps the saved mark visible and shows the rest on hover
rating rating Number — Current rating (1–5), or null. Passed through to the popover and echoed on every state change
userKey user-key String '' "/people/<username>" when signed in; empty sends the visitor to log in instead of opening the popover
labels labels Object {} Translated strings, merged over DEFAULT_LABELS
hideRating hide-rating Boolean false Always drop the popover's stars. Without it the popover drops them itself while a visible star form for the same book is on the page
listsOnly lists-only Boolean false No shelf to act on; only the lists pane
pending pending Boolean false The reader's state is not known yet. The button dims and drops its clicks, and the popover holds its rows: posting a shelf the book is already on removes it, so a guess could undo a save. Set by whoever fetches the state (book-state.js, the search modal)
size size "large" — Scales the `icon` badge up for a big cover: a bigger circle a shade translucent, so the cover tints it, and the glyph stamped into it. Unset is the carousel-card size
readDate read-date String — Check-in date, whole or partial, shown on the popover's Already Read row. Applied by the surface, like shelf and rating
eventId event-id Number — Id of that check-in, so editing the date amends it
placement placement String — ol-popover placement for the actions panel; unset uses its default
Events
EventTypeDescription
ol-book-state-change CustomEvent The shelf or rating changed, optimistically or rolled back. detail: { key, shelf, rating }
ol-book-check-in — Re-fired from the popover when a finish date is saved or removed. detail: { key, date, eventId }, both null on removal
CSS custom properties
PropertyDefaultDescription
--ol-shelf-badge-sheen-position 100% Where the sheen, a soft slanted band of shade, sits across the badge's circle: 100% parked off its right edge, 0% off its left. A page moves it to sweep the sheen across the badge as the cover it floats over tilts
--ol-shelf-badge-sheen-duration 0.8s How long that sweep takes; set it to the cover's own tilt duration so the two move as one

Shelf Actions <ol-shelf-actions>

The per-book action popover: the four reading-log shelves, a star rating, an "Add to list" pane that slides in with a filter and inline list creation, and a check-in pane that asks when the reader finished the book. It composes Popover for the shell; you supply the trigger. State is optimistic — the UI updates first, an error toast rolls it back — and every accepted change fires ol-book-state-change so the surrounding card can follow. Only render it for signed-in readers; send everyone else to log in.

Standalone

Give it a book, the current shelf/rating, and the reader's key (needed to create lists). book takes JSON in the attribute, or the object itself if you set the .book property from JS; shelf and rating are plain numbers. The trigger is whatever you slot in. Signed in, this demo opens on your real state for The Lord of the Rings and saves every change to your account.

Save

Last change: —

<ol-shelf-actions
  user-key="/people/you"
  shelf="1"
  book='{"key": "/works/OL27448W",
         "title": "The Lord of the Rings",
         "firstPublishYear": 1954}'>
  <button slot="trigger">Save</button>
</ol-shelf-actions>

Shelf ids. 1 Want to Read, 2 Currently Reading, 3 Already Read, 4 Stopped Reading — the same ids as Bookshelves.PRESET_BOOKSHELVES and the bookshelf_id the endpoint takes. From JS, import SHELF from lit/utils/books-api.js instead of writing the number.

Recently used lists. Curation runs in bursts — many books into one list in a sitting — so the component remembers the last three lists the reader put a book in or took one out of, per user, for a fortnight (lit/utils/recent-lists.js, localStorage). The most recent one gets a one-tap row on the main pane, so the common add never opens the lists pane at all; from five lists up, the recent ones are pinned above the rest inside it, and the filter field appears on the same terms as Select Popover's (both read FILTER_THRESHOLD). Order is snapshotted when the popover opens and held until it closes, so a row never moves under the reader mid-session. With no lists at all the pane is the create form, headed Create your first list — nothing to filter, and one thing to do.

Requests. Shelves post to /works/OL…W/bookshelves.json, ratings to /works/OL…W/ratings.json, check-ins to /works/OL…W/check-ins and DELETE /check-ins/…, lists through /partials/MyBooksDropperLists.json (read) and the lists/seeds endpoints (write). A 401 on any of them redirects to login.

API reference
Properties
PropertyAttributeTypeDefaultDescription
book book Object — `{ key, title, firstPublishYear?, editionKey? }`
shelf shelf Number — Current shelf id (1–4) or null
rating rating Number — Current rating (1–5) or null
userKey user-key String '' "/people/<username>", needed to create lists
labels labels Object {} Translated strings (see DEFAULT_LABELS)
hideRating hide-rating Boolean false Always drop the stars. Without it they go on their own whenever a visible `.star-rating-form` for the same book is on the page, checked at each open
listsOnly lists-only Boolean false Only the lists pane, opened straight into: for a seed with no work to shelve, an author or an edition on its own. `book.key` is then that seed's key, and its title the heading
pending pending Boolean false The reader's state is not known yet. The shelf and rating rows dim and ignore clicks until it is: posting the shelf a book is already on removes it, so a guess could undo a save
readDate read-date String — The check-in date, whole or partial ("2026", "2026-08", "2026-08-22"), or null when the reader has not given one
eventId event-id Number — Id of that check-in, so changing the date edits it rather than recording a second finish
placement placement String — ol-popover placement; unset uses its default
anchorElement — Element — ol-popover anchorElement: what the panel lines up under when the trigger is only part of a control
Events
EventTypeDescription
ol-book-state-change CustomEvent After a shelf or rating change is accepted by the server. detail: { key, shelf, rating }
ol-book-check-in CustomEvent After a finish date is saved or removed. The component keeps its own copy (`readDate`/`eventId`); the event is for the surface to persist it across renders. detail: { key, date, eventId } — `date` is whole or partial, as stored, and both are null on removal. A book coming off its shelf drops its date too, which the surface hears as ol-book-state-change instead.
ol-list-change CustomEvent After the book is put in a list or taken out of one. detail: { key, name, seedKey, member } — `member` is whether it is in the list now
ol-list-created CustomEvent After the inline form creates a list. Sibling popovers share the lists store and need no event; this is for surfaces outside the components. detail: { key, name, seedKey }
Slots
SlotDescription
trigger The button that opens the popover.