Icons
Gallery
One curated set, every glyph on a 24×24 grid at a single stroke weight, themed with
currentColor. Shown here at lg (24px). Click a glyph to copy it in any of
the four usage forms.
Which technique
Two ways to render a glyph, both drawing from the same source
(static/icons/src/), so an icon looks identical either way.
Pick by who renders the markup.
| Where the icon lives | What to use |
|---|---|
| A server-rendered template, Templetor or Jinja. The default. |
$:macros.icon() in Templetor, {{ icon() }} in
Jinja. One macro behind both — a plain inline <svg> against the
sprite asset, one file cached sitewide. No JavaScript, no layout shift.
|
| Anywhere the client renders the markup — a Lit template, a web component's own shadow DOM, or plain HTML handed to an outside consumer. |
The <ol-icon> element. One element everywhere: it inlines
the glyph into its own shadow root, so it needs neither the sprite nor the global
stylesheet and works just as well nested inside another component. The box is
reserved so nothing shifts, but the glyph appears only once the JS upgrades it —
prefer the macro whenever the markup is server-rendered.
|
Usage
In a Templetor template
Pass label for an accessible name; without one the icon is aria-hidden.
$:macros.icon("search")
$:macros.icon("globe", size="lg")
$:macros.icon("globe", label=_("Language"))
In a Jinja template
The same macro, reached through the icon global — nothing to import or pass in.
{{ icon("search") }}
{{ icon("globe", size="lg") }}
{{ icon("globe", label=_("Language")) }}
In a Lit component
Import the element once, then use it like any other tag — including inside your own shadow
root. Size it from your component's CSS; stroke width follows the size unless you set
--ol-icon-stroke-width. The import is only for modules inside the Lit bundle;
code in the page-JS bundle already has <ol-icon> registered site-wide and must
not import it, or the second customElements.define() throws and takes the
rest of the Lit bundle down with it.
import './OlIcon.js';
html`<ol-icon name="search"></ol-icon>`
As a custom element
Hand-written markup, or markup a script builds at runtime. Nothing to import — the main
bundle registers <ol-icon> site-wide. Prefer the macro when the markup is
server-rendered: the box is reserved up front, but the glyph only appears once the JS
upgrades the element.
The same element, in plain markup.
<ol-icon name="search">
</ol-icon>
<ol-icon name="globe" size="lg" label="Language">
</ol-icon>
API reference
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
name
|
name
|
String
|
''
|
Icon name, e.g. "search". See /developers/design#icons. |
size
|
size
|
'sm' | 'md' | 'lg'
|
'md'
|
"sm" (16px) | "md" (20px, default) | "lg" (24px). |
label
|
label
|
String
|
''
|
Accessible name; exposes the icon as role="img". Without it the icon is aria-hidden. |
filled
|
filled
|
Boolean
|
false
|
Paint the glyph's interior in currentColor, for on/off states like a saved bookmark or a rated star. |
CSS custom properties
| Property | Default | Description |
|---|---|---|
--ol-icon-stroke-width
|
— | Stroke weight, overriding the size default. |
What you control
Size
| Preset | Box | Stroke, as drawn | Reach for it when |
|---|---|---|---|
sm |
16px 1rem | 1.33px | Inline with body text, dense toolbars. |
md |
20px 1.25rem | 1.67px | The default. Almost everything — buttons, menu rows, form affordances. |
lg |
24px 1.5rem | 1.75px | Standalone icon buttons, section headers. |
Size scales the stroke with it. The box scales the whole 24×24 grid, geometry and stroke
together, so the weight stays a roughly constant fraction of the icon rather than a fixed
hairline. That is deliberate: a fixed stroke would make sm read as a heavier,
different family. lg is the one step pulled back from the raw ratio — see
stroke weight.
Each preset is a token — --icon-size-sm, -md, -lg — declared
in rem, so the pixel figures above hold at the default 16px root and grow with the
reader's font size. The macro and <ol-icon> both take
size="sm|md|lg" — there is no off-scale value. A genuine one-off is a CSS
job: set width/height on the <ol-icon> host (the
inner <svg> fills it) or on a fragment import's own wrapping
<svg>.
$:macros.icon("search", size="sm")
$:macros.icon("search", size="md")
$:macros.icon("search", size="lg")
Color
Every glyph is drawn in currentColor, so an icon is simply the text color of wherever
it sits. Recolor it by setting color on the icon or any ancestor — say
.danger { color: var(--color-error-fg) } — with no fill or
stroke override needed.
<span class="danger">$:macros.icon("trash")</span>
Stroke weight
One weight across the whole set: 2 units on the 24×24 grid, which the box scales down with
the geometry — 1.33px at sm, 1.67px at md. Carried through, lg
would land at a full 2px and read noticeably heavier than the same glyph one step down, so it is
corrected to 1.75. Absolute weight then stays near-constant across the scale
(1.33 / 1.67 / 1.75px) instead of growing with the box.
Weight is a per-step token — --icon-stroke-sm, -md, -lg — and
not baked into the sprite: the build strips stroke-width from each
<symbol> precisely so the value can be inherited from the referencing
<svg>. (A presentation attribute on the symbol would outrank any inherited value,
and CSS cannot select into a <use> shadow tree, so a baked weight would be
unoverridable.) Tune the set from those tokens; a component with a genuine reason can set
stroke-width on its own wrapper, but that is the exception.
Where glyphs come from
Every glyph is a plain SVG file committed under
static/icons/src/<source>/, normalized to the canonical form — 24×24 grid,
2px stroke, currentColor. The folder name records provenance and nothing else:
the build reads every folder identically, and callers address bare names
(icon("search")) that never mention a library.
Why Lucide
Most geometry today is vendored from Lucide, the ISC-licensed community fork of Feather — chosen because its design language already is our canonical form, and its coverage makes most icon needs a copy-and-normalize away. That is a head start, not an attachment: there is no package dependency and no runtime coupling, so replacing a glyph's geometry later changes one file, never an API or a call site.
Mixing sources is the design
custom/ holds original Open Library glyphs, and glyphs adapted from another
library get a folder of their own — the build discovers new folders automatically. The
one real rule is aesthetic, not technical: a newcomer must be redrawn to the canonical
form so the set still reads as a single family. The recipe is
below.
Adding an icon
-
Author the SVG in the canonical form —
viewBox="0 0 24 24",stroke="currentColor", nowidth/height, noids. A glyph copied from lucide.dev is already in this form — paste it verbatim. (Astroke-widthin the source is fine; the build strips it so weight stays themable.) -
Name the file in kebab-case and drop it in the folder matching its provenance —
static/icons/src/lucide/for Lucide geometry,src/custom/for original Open Library work. The file name becomes the icon's name everywhere:pencil.svgisicon("pencil"). A glyph from a new library gets a new folder plus a license section instatic/icons/ATTRIBUTION.md. -
Run
make icons. One step rebuilds both outputs:static/icons/sprite.svgand the Lit glyph module the<ol-icon>bundle imports. Neither is committed, so the source SVG is the only file in the commit.
That's it — the icon now renders through both techniques and appears in the gallery above.