Pulsar UI kit
For your own HTML pages: popups, options and full pages. One CSS file with the app’s exact colours, sizes and motion, a tiny script for the parts that move, and the icon set. No framework.
Set up
Copy the three files into your extension (pages inside an extension can’t load them from the web), then:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<link rel="stylesheet" href="ui/pulsar-ui.css">
</head>
<body class="pl-page">
<main class="pl-wrap">
…
</main>
<script type="module" src="ui/pulsar-ui.js"></script>
</body>
</html>pl-pageon<body>sets the font, colours and ground. Useplon any element instead to scope it.- Dark mode follows the phone. Set
data-theme="light"or"dark"on<html>(or callPulsarUI.theme()) to force one. - Icons:
<svg class="pl-icon"><use href="ui/pulsar-icons.svg#shield"/></svg>. Pulsar’s font is Inter; bundle it if you want it, else the phone’s own font is used. - Right-to-left works: the kit uses logical properties. Add
pl-flipto arrows that should point the other way.
pulsar-ui.js
An ES module that also sets window.PulsarUI. On load it wires everything in the page: [data-pl-choice] groups, .pl-range sliders, [role=tablist] tabs, .pl-switch (adds role="switch") and [data-pl-sheet] buttons. Call PulsarUI.init(el) for content you add later.
| Function | Does |
|---|---|
| toast(text, {action, onAction, icon, duration}) | A snackbar above the bottom edge. One at a time. Returns {close()}. |
| sheet.open(el, {onClose}) / sheet.close(el?) | Slides a .pl-sheet up over a scrim. Focus stays inside; Esc, the scrim and [data-pl-close] close it. |
| menu(anchor, items, {label}) | A menu under the button: [{label, icon, danger, disabled, onSelect}, "-"]. Arrow keys, Esc, tap outside. Returns {close()}. |
| dialog({title, body, confirm, cancel, danger}) | Resolves true or false. cancel: null leaves only the confirm button. |
| theme("auto" | "light" | "dark") | Sets the theme; with no argument returns it. Fires pl-theme on document. |
| icon(name, {size, label}) | An <svg> from the icon set. |
| init(root) | Wires the controls inside root. |
import PulsarUI from "./ui/pulsar-ui.js";
// chips, segmented controls and card chips
document.getElementById("size").addEventListener("pl-change", (e) => save({ size: e.detail.value }));
// sliders: "input" while dragging, "pl-change" with a number when the finger lifts
document.getElementById("bright").addEventListener("pl-change", (e) => save({ bright: e.detail.value }));
// switches are checkboxes: use their own "change"
document.getElementById("ads").addEventListener("change", (e) => save({ ads: e.target.checked }));
// set a choice from code (no event)
document.getElementById("size").plSet("l");Tokens
Every value is a CSS custom property you can use in your own styles. They are the app’s own (tokens.xml), light and dark.
--pl-gutter: 16px; --pl-button: 48px; --pl-touch: 48px; --pl-icon: 22px;
--pl-radius-card: 20px; --pl-radius-hero: 24px; --pl-radius-sheet: 28px;
--pl-radius-tile: 16px; --pl-radius-tile-small: 12px; --pl-radius-menu: 16px; --pl-menu-row: 48px;
--pl-short: 120ms; --pl-medium: 200ms; --pl-long: 280ms; --pl-menu-in: 170ms; --pl-menu-out: 110ms;
--pl-ease-standard: cubic-bezier(.4, 0, .2, 1); /* moving */
--pl-ease-decelerate: cubic-bezier(0, 0, .2, 1); /* entering */
--pl-ease-accelerate: cubic-bezier(.4, 0, 1, 1); /* leaving */
--pl-ease-emphasized: cubic-bezier(.05, .7, .1, 1); /* sheets */Typography
Inter, with the phone’s own font as the fallback. Titles 15 semibold, body 15 regular, small 13 medium.
Type scale
<div class="pl-stack">
<h1 class="pl-h1">Heading 22</h1>
<h2 class="pl-h2">Sub-heading 17</h2>
<p class="pl-title">Title 15 semibold</p>
<p class="pl-body">Body 15 regular. Short, plain sentences read best on a phone.</p>
<p class="pl-small pl-mut">Small 13 medium, muted</p>
<p class="pl-caption">Caption 12 · faint</p>
<p class="pl-body">Inline <code>code</code> and <span class="pl-mono">mono 13</span></p>
</div>Switches and choices
The switch is the app’s own: a 40 × 24 track, an 18 px knob, 200 ms on the standard curve.
Switch
A checkbox with class pl-switch. pulsar-ui.js adds role="switch". Put it in a label so the whole row is the target.
<div class="pl-list">
<label class="pl-row">
<span class="pl-row-text"><span class="pl-row-title">Block ads</span><span class="pl-row-sub">On every site</span></span>
<input class="pl-switch" type="checkbox" checked>
</label>
<label class="pl-row">
<span class="pl-row-text"><span class="pl-row-title">Show a badge</span></span>
<input class="pl-switch" type="checkbox">
</label>
<label class="pl-row">
<span class="pl-row-text"><span class="pl-row-title">Sync (needs an account)</span></span>
<input class="pl-switch" type="checkbox" disabled>
</label>
</div>Checkbox and radio
<div class="pl-card">
<label class="pl-choice"><input class="pl-check" type="checkbox" checked> Images</label>
<label class="pl-choice"><input class="pl-check" type="checkbox"> Videos</label>
<label class="pl-choice"><input class="pl-check" type="checkbox" disabled> Fonts</label>
<hr class="pl-divider">
<label class="pl-choice"><input class="pl-radio" type="radio" name="when" checked> Every day</label>
<label class="pl-choice"><input class="pl-radio" type="radio" name="when"> Every week</label>
</div>Chips
data-pl-choice makes one chip the choice: arrow keys move, and the group sends a pl-change event with the value.
<div class="pl-chips" data-pl-choice aria-label="Strength">
<button class="pl-chip" data-value="off">Off</button>
<button class="pl-chip" data-value="std" aria-checked="true">Standard</button>
<button class="pl-chip" data-value="strict">Strict</button>
</div>Segmented control
<div class="pl-seg" data-pl-choice aria-label="Text size">
<button class="pl-seg-btn" data-value="s">Small</button>
<button class="pl-seg-btn" data-value="m" aria-checked="true">Medium</button>
<button class="pl-seg-btn" data-value="l">Large</button>
</div>Cards with a swatch
The Dark / Dim / Sepia picker. Set the colour with --pl-swatch.
<div class="pl-cardchips" data-pl-choice aria-label="Theme">
<button class="pl-cardchip" data-value="dark" aria-checked="true"><span class="pl-swatch" style="--pl-swatch:#212121"></span>Dark</button>
<button class="pl-cardchip" data-value="dim"><span class="pl-swatch" style="--pl-swatch:#4A4A4A"></span>Dim</button>
<button class="pl-cardchip" data-value="sepia"><span class="pl-swatch" style="--pl-swatch:#F3EAD7"></span>Sepia</button>
</div>Slider with value
pulsar-ui.js paints the filled part and writes the value into the output named by data-pl-value-for.
<div class="pl-card pl-slider">
<div class="pl-slider-head">
<label class="pl-title" for="bright">Brightness</label>
<output class="pl-slider-value" data-pl-value-for="bright">90%</output>
</div>
<input class="pl-range" type="range" id="bright" min="60" max="100" step="5" value="90" data-unit="%">
<div class="pl-slider-scale"><span>60%</span><span>100%</span></div>
</div>Fields
Pill fields on the field colour, 48 px high. The border shows on hover and focus.
Text field
<div class="pl-stack">
<div class="pl-formrow">
<label class="pl-label" for="name">Name</label>
<input class="pl-field" id="name" placeholder="My list">
<p class="pl-help">Shown on your list’s card.</p>
</div>
<div class="pl-formrow">
<label class="pl-label" for="site">Site</label>
<input class="pl-field" id="site" value="news" aria-invalid="true" aria-describedby="site-err">
<p class="pl-error" id="site-err">Type a full address, like news.example</p>
</div>
<input class="pl-field" placeholder="Disabled" disabled>
</div>Text area
<div class="pl-formrow">
<label class="pl-label" for="css">Custom CSS</label>
<textarea class="pl-textarea pl-textarea--mono" id="css" placeholder="a { … }"></textarea>
</div>Search field
The clear button hides while the field is empty.
<div class="pl-search">
<svg class="pl-icon"><use href="pulsar-icons.svg#search"/></svg>
<input class="pl-field" type="search" placeholder="Find a site" aria-label="Find a site">
<button class="pl-icon-btn" type="button" aria-label="Clear"><svg class="pl-icon pl-icon--20"><use href="pulsar-icons.svg#close"/></svg></button>
</div>Select
<div class="pl-formrow">
<label class="pl-label" for="when">Check for changes</label>
<select class="pl-select" id="when">
<option>Every hour</option>
<option selected>Every day</option>
<option>Every week</option>
</select>
</div>Lists and setting rows
Rows of at least 56 px inside a card with a 20 px radius. Section titles above, footnotes below.
Setting rows
An icon, a title and a sub, then a value, a chevron or a switch. Use a button or a link for rows that open something.
<section class="pl-section">
<h2 class="pl-section-title">Blocking</h2>
<div class="pl-list">
<label class="pl-row">
<span class="pl-row-icon"><svg class="pl-icon"><use href="pulsar-icons.svg#shield"/></svg></span>
<span class="pl-row-text"><span class="pl-row-title">Block trackers</span><span class="pl-row-sub">On every site</span></span>
<input class="pl-switch" type="checkbox" checked>
</label>
<button class="pl-row pl-row--action">
<span class="pl-row-icon"><svg class="pl-icon"><use href="pulsar-icons.svg#globe"/></svg></span>
<span class="pl-row-text"><span class="pl-row-title">Allowed sites</span></span>
<span class="pl-row-value">4</span><svg class="pl-icon pl-row-chev"><use href="pulsar-icons.svg#chevron"/></svg>
</button>
<button class="pl-row pl-row--action pl-row--danger">
<span class="pl-row-icon"><svg class="pl-icon"><use href="pulsar-icons.svg#trash"/></svg></span>
<span class="pl-row-text"><span class="pl-row-title">Clear site data</span><span class="pl-row-sub">12 MB</span></span>
</button>
</div>
<p class="pl-footnote">Pages that break can be allowed one by one.</p>
</section>Divided list
Add pl-list--divided for long lists of people or sites.
<div class="pl-list pl-list--divided">
<div class="pl-row"><span class="pl-avatar pl-avatar--sm">N</span><span class="pl-row-text"><span class="pl-row-title">news.example</span><span class="pl-row-sub">21 pages</span></span><span class="pl-badge pl-badge--ok">On</span></div>
<div class="pl-row"><span class="pl-avatar pl-avatar--sm">L</span><span class="pl-row-text"><span class="pl-row-title">longreads.example</span><span class="pl-row-sub">17 pages</span></span><span class="pl-badge">Off</span></div>
<div class="pl-row"><span class="pl-avatar pl-avatar--sm">D</span><span class="pl-row-text"><span class="pl-row-title">docs.example</span><span class="pl-row-sub">6 pages</span></span><span class="pl-badge pl-badge--ok">On</span></div>
</div>Cards, stats and media
Hairline borders, no shadows.
Card
<div class="pl-card">
<div class="pl-card-head">
<span class="pl-tile pl-tint-violet"><svg class="pl-icon"><use href="pulsar-icons.svg#star"/></svg></span>
<span class="pl-row-text"><span class="pl-title">Price Watch</span><span class="pl-small pl-mut">3 prices dropped</span></span>
</div>
<p class="pl-body pl-mut">We check your saved items once a day and tell you when one costs less.</p>
</div>Stats tiles
Bars are i elements with --h; the last one is ink (today).
<div class="pl-stats">
<div class="pl-stat"><span class="pl-stat-label">On this page</span><span class="pl-stat-value">14</span><span class="pl-stat-sub">ads and trackers</span></div>
<div class="pl-stat"><span class="pl-stat-label">This week</span>
<div class="pl-bars" role="img" aria-label="3, 5, 2, 8, 6, 9, 14">
<i style="--h:21%"></i><i style="--h:36%"></i><i style="--h:14%"></i><i style="--h:57%"></i><i style="--h:43%"></i><i style="--h:64%"></i><i style="--h:100%"></i>
</div>
</div>
</div>Media card
<div class="pl-stats">
<button class="pl-media"><span class="pl-media-pic pl-tint-blue"><svg class="pl-icon"><use href="pulsar-icons.svg#video"/></svg><span class="pl-media-chip">43 min</span></span><span class="pl-media-title">Mountain Lakes</span><span class="pl-media-sub">Blue Hour · 1080p</span></button>
<button class="pl-media"><span class="pl-media-pic pl-tint-pink"><svg class="pl-icon"><use href="pulsar-icons.svg#music"/></svg></span><span class="pl-media-title">Night Drive</span><span class="pl-media-sub">12 songs</span></button>
</div>Feedback
Green only means done or on. Red only means danger. Amber warns. Blue informs.
Badges, pills and tags
<div class="pl-stack">
<div class="pl-hstack"><span class="pl-badge">12</span><span class="pl-badge pl-badge--ok"><svg class="pl-icon pl-icon--16"><use href="pulsar-icons.svg#check"/></svg>Saved</span><span class="pl-badge pl-badge--warn">Needs a look</span><span class="pl-badge pl-badge--danger">Failed</span><span class="pl-badge pl-badge--info">New</span><span class="pl-badge pl-badge--outline">DEV</span></div>
<div class="pl-hstack"><span class="pl-pill"><span class="pl-dot"></span>Running</span><span class="pl-tag">reading</span><span class="pl-tag">offline</span></div>
</div>Banners
<div class="pl-stack">
<div class="pl-banner pl-banner--info"><svg class="pl-icon"><use href="pulsar-icons.svg#info"/></svg><div class="pl-banner-text"><span class="pl-banner-title">New in 1.2</span><span>Lists now sync to your other phones.</span></div></div>
<div class="pl-banner pl-banner--ok"><svg class="pl-icon"><use href="pulsar-icons.svg#check"/></svg><div class="pl-banner-text">Your list is saved.</div></div>
<div class="pl-banner pl-banner--warn"><svg class="pl-icon"><use href="pulsar-icons.svg#warning"/></svg><div class="pl-banner-text">This site may break with strict blocking.</div><button class="pl-btn pl-btn--secondary pl-btn--sm pl-banner-action">Allow</button></div>
<div class="pl-banner pl-banner--danger"><svg class="pl-icon"><use href="pulsar-icons.svg#warning"/></svg><div class="pl-banner-text"><span class="pl-banner-title">Couldn’t save</span><span>Check your connection and try again.</span></div></div>
</div>Toast
PulsarUI.toast(text, {action, onAction, icon}). One at a time, above the bottom edge, 3.5 s (6 s with an action).
<div class="pl-stack">
<button class="pl-btn pl-btn--secondary" id="t1">Show a toast</button>
<div class="pl-toast pl-toast--static"><svg class="pl-icon"><use href="pulsar-icons.svg#check"/></svg><span class="pl-toast-text">Site choices forgotten</span><button class="pl-toast-action">Undo</button></div>
</div>Empty state
<div class="pl-card">
<div class="pl-empty">
<span class="pl-empty-tile"><svg class="pl-icon"><use href="pulsar-icons.svg#star"/></svg></span>
<span class="pl-empty-title">No saved items yet</span>
<span class="pl-empty-sub">Tap the star on a product page to watch its price.</span>
<button class="pl-btn pl-btn--secondary pl-btn--md">How it works</button>
</div>
</div>Spinner and progress
With reduced motion the spinner becomes a soft pulsing green dot, as in the app.
<div class="pl-stack">
<div class="pl-hstack" style="gap:16px">
<span class="pl-spinner pl-spinner--16" role="status" aria-label="Loading"></span>
<span class="pl-spinner" role="status" aria-label="Loading"></span>
<span class="pl-spinner pl-spinner--28" role="status" aria-label="Loading"></span>
<div class="pl-ring" style="--value:72" role="img" aria-label="72%"><span class="pl-ring-label">72%</span></div>
<div class="pl-ring pl-ring--ok" style="--value:100" role="img" aria-label="Done"><svg class="pl-icon"><use href="pulsar-icons.svg#check"/></svg></div>
</div>
<div class="pl-progress" role="progressbar" aria-valuenow="40" aria-valuemin="0" aria-valuemax="100"><span style="--value:40%"></span></div>
<div class="pl-progress pl-progress--indeterminate" role="progressbar" aria-label="Loading"><span></span></div>
</div>Skeleton
<div class="pl-list" aria-hidden="true">
<div class="pl-row"><span class="pl-skel pl-skel--tile"></span><span class="pl-row-text" style="gap:8px"><span class="pl-skel pl-skel--title"></span><span class="pl-skel pl-skel--line" style="width:40%"></span></span></div>
<div class="pl-row"><span class="pl-skel pl-skel--tile"></span><span class="pl-row-text" style="gap:8px"><span class="pl-skel pl-skel--title" style="width:70%"></span><span class="pl-skel pl-skel--line" style="width:30%"></span></span></div>
</div>Structure
Bars, tabs, dividers, avatars and icon tiles.
Page bar
For full pages opened in a tab. Sticks to the top.
<header class="pl-bar">
<button class="pl-icon-btn" aria-label="Back"><svg class="pl-icon pl-flip"><use href="pulsar-icons.svg#back"/></svg></button>
<h1 class="pl-bar-title">Your reading week</h1>
<button class="pl-icon-btn" aria-label="More"><svg class="pl-icon"><use href="pulsar-icons.svg#more"/></svg></button>
</header>Tabs
<div class="pl-tabs pl-tabs--fill" role="tablist" aria-label="Lists">
<button class="pl-tab" role="tab" aria-controls="p1" aria-selected="true">Days</button>
<button class="pl-tab" role="tab" aria-controls="p2">Sites</button>
<button class="pl-tab" role="tab" aria-controls="p3">Notes</button>
</div>
<div class="pl-tabpanel" id="p1"><p class="pl-body">Seven days of reading.</p></div>
<div class="pl-tabpanel" id="p2" hidden><p class="pl-body">The sites you read most.</p></div>
<div class="pl-tabpanel" id="p3" hidden><p class="pl-body">What you saved.</p></div>Avatars, tiles and dividers
Tiles take a tint class: blue, red, orange, green, teal, amber, violet, pink, sky, slate.
<div class="pl-stack">
<div class="pl-hstack" style="gap:12px">
<span class="pl-avatar">AK</span>
<span class="pl-avatar pl-avatar--sm">M</span>
<span class="pl-tile pl-tint-blue"><svg class="pl-icon"><use href="pulsar-icons.svg#file"/></svg></span>
<span class="pl-tile pl-tint-green"><svg class="pl-icon"><use href="pulsar-icons.svg#shield"/></svg></span>
<span class="pl-tile pl-tint-amber"><svg class="pl-icon"><use href="pulsar-icons.svg#bolt"/></svg></span>
<span class="pl-tile pl-tile--ink"><svg class="pl-icon"><use href="pulsar-icons.svg#puzzle"/></svg></span>
<span class="pl-tile pl-tile--lg pl-tint-violet"><svg class="pl-icon"><use href="pulsar-icons.svg#palette"/></svg></span>
</div>
<hr class="pl-divider">
<p class="pl-small pl-mut">A divider is one hairline, 12 px above and below.</p>
</div>Menus, sheets and dialogs
Opened with pulsar-ui.js: focus stays inside, Esc and the scrim close them, focus goes back to the button.
Bottom sheet
A .pl-sheet with hidden; PulsarUI.sheet.open(el) slides it up over a scrim (280 ms). [data-pl-close] inside closes it. Sheets fit their content.
<button class="pl-btn pl-btn--secondary" data-pl-sheet="s1">Open the sheet</button>
<div class="pl-sheet" id="s1" hidden>
<span class="pl-grab" aria-hidden="true"></span>
<div class="pl-sheet-head">
<span class="pl-tile pl-tint-blue"><svg class="pl-icon"><use href="pulsar-icons.svg#globe"/></svg></span>
<div class="pl-row-text"><h2 class="pl-sheet-title">Allow on news.example?</h2><p class="pl-sheet-sub">You can change this later</p></div>
</div>
<p class="pl-body pl-mut">Price Watch reads prices on this site. Nothing leaves your phone.</p>
<div class="pl-sheet-actions">
<button class="pl-btn pl-btn--block" data-pl-close>Allow</button>
<button class="pl-btn pl-btn--ghost pl-btn--block" data-pl-close>Not now</button>
</div>
</div>Dialog
await PulsarUI.dialog({title, body, confirm, cancel, danger}) → true or false. For a danger dialog, focus starts on Cancel.
<div class="pl-stack">
<div class="pl-dialog pl-dialog--static" role="dialog" aria-labelledby="d-t">
<h2 class="pl-dialog-title" id="d-t">Remove this list?</h2>
<p class="pl-dialog-body">Its 14 items go too.</p>
<div class="pl-dialog-actions"><button class="pl-btn pl-btn--ghost pl-btn--md">Cancel</button><button class="pl-btn pl-btn--danger pl-btn--md">Remove</button></div>
</div>
<button class="pl-btn pl-btn--secondary" id="d1">Open a dialog</button>
</div>Tooltip
data-pl-tip on any element: shows on hover and keyboard focus. On phones, prefer a visible label.
<div class="pl-hstack" style="gap:24px">
<button class="pl-icon-btn pl-icon-btn--field is-tip" aria-label="Sync now" data-pl-tip="Sync now"><svg class="pl-icon"><use href="pulsar-icons.svg#bolt"/></svg></button>
<span class="pl-tooltip">Sync now</span>
</div>