Component playground
Live, hand-testable examples of every component in @f-ewald/components.
action-bar
Toolbar above a list or table: search and filters on the left, record actions on the
right. Presentational only — drop your own controls into the start and
end slots.
import "@f-ewald/components/action-bar.js";
<action-bar>
<autocomplete-input slot="start" placeholder="Search…"></autocomplete-input>
<ui-button slot="end" variant="secondary">Delete</ui-button>
<ui-button slot="end" variant="primary">Create</ui-button>
</action-bar>
address-autocomplete
Form-associated address input with a suggestion dropdown — backed here by a local
array (try "St", "Ave", or "London"), or point endpoint at a geocoding API instead.
import "@f-ewald/components/address-autocomplete.js";
<address-autocomplete clearable placeholder="Start typing an address…"></address-autocomplete>
<script type="module">
// Local mode: filters client-side, no network request.
document.querySelector("address-autocomplete").suggestions = [
{ address: "1 Infinite Loop, Cupertino, CA", lat: 37.3318, lng: -122.0312 },
{ address: "10 Downing Street, London", lat: 51.5034, lng: -0.1276 },
];
// API mode: omit `suggestions` and set `endpoint` + `access-token` instead.
</script>
animate-confetti
Fullscreen confetti burst overlay.
import "@f-ewald/components/animate-confetti.js";
<animate-confetti duration="6000"></animate-confetti>
app-shell
The dashboard page shell: an always-full-width top bar with a built-in toggle, the main
content, an optional right-hand detail column, and a footer. The sidebar is closed by
default; the built-in ☰ toggle (or pressing [ outside a text field) shows it.
Independently of that, sidebar-mode picks overlay (floats above
content, covering the top bar's corner, never resizing main/footer)
or push (reserves a real grid column instead, so content reflows around it —
the top bar still spans full width either way), and sidebar-width picks
full (16rem, icons + labels) or icon (3.5rem rail, icons only).
Below 48rem the sidebar always behaves as a full-screen, scrim-dismissible drawer regardless
of those two settings. Open the dialog below while the sidebar is open to see it correctly
render above both. Give the shell a height so it can fill and scroll. Consumers with their
own external top bar can set no-topbar to drop the built-in row entirely — the
[/Escape shortcuts stay active either way.
Workspace
Dashboard Members ScheduleSelected member
Pick a row to see its details here. On narrow screens this becomes an overlay.
import "@f-ewald/components/app-shell.js";
import "@f-ewald/components/app-sidebar.js";
<app-shell sidebar-open sidebar-mode="push" sidebar-width="icon" detail-open style="height: 100vh">
<app-sidebar slot="sidebar">…nav items…</app-sidebar>
<page-header slot="topbar" heading="Members"></page-header>
<action-bar>…</action-bar>
<data-table></data-table>
<div slot="detail">…</div>
<pagination-nav slot="footer" total-pages="5"></pagination-nav>
</app-shell>
audio-player
Play/pause, elapsed/total time, a seekable progress bar, and a mute toggle + volume
slider around a native <audio> element.
events:
import "@f-ewald/components/audio-player.js";
<audio-player src="/track.mp3" label="Episode 12"></audio-player>
auto-scroll
Wraps content (here, timeline-container) and keeps it scrolled to the
bottom as children are added — but only while already scrolled near the bottom. Scroll
up first, then click "Add message", to see it stay put; a "Jump to latest" affordance
appears while unpinned. Needs a bounded height on the host (here 12rem).
import "@f-ewald/components/auto-scroll.js";
<auto-scroll style="height: 24rem">
<timeline-container>
<timeline-entry>...</timeline-entry>
</timeline-container>
</auto-scroll>
autocomplete-input
Generic form-associated autocomplete for any {key, value} option list —
backed here by a local array (try "Type" or "Java"), or point endpoint at
an API that returns [{key, value}] for a query string instead.
import "@f-ewald/components/autocomplete-input.js";
<form>
<autocomplete-input clearable name="language" placeholder="Start typing a language…"></autocomplete-input>
</form>
<script type="module">
// Local mode: filters client-side, no network request.
document.querySelector("autocomplete-input").options = [
{ key: "ts", value: "TypeScript" },
{ key: "py", value: "Python" },
];
// API mode: omit `options` and set `endpoint` instead — it's queried as
// `${endpoint}?${queryParam}=` and must respond with [{key, value}].
</script>
blink-cursor
A small blinking text-cursor glyph for terminal- or editor-styled headings and
prompts. Uses the --ui-primary accent so it reads as an active insertion
point; the blink stops under prefers-reduced-motion.
ponytail
~/ponytail ❯
import "@f-ewald/components/blink-cursor.js";
<h1>ponytail<blink-cursor></blink-cursor></h1>
<p>~/ponytail ❯ <blink-cursor char="|"></blink-cursor></p>
calendar-day
One day as an hourly time grid. Declarative calendar-entry children
with a time of day in start/end (e.g.
start="2026-07-15T09:00") render as positioned blocks — overlapping
ones stack side by side. Entries with only a date render in the all-day band above
the grid. The actions slot renders beside the day name for controls
like day-navigation buttons.
import "@f-ewald/components/calendar-day.js";
<calendar-day date="2026-07-15">
<ui-button slot="actions" variant="secondary" size="sm" aria-label="Previous day">←</ui-button>
<ui-button slot="actions" variant="secondary" size="sm" aria-label="Next day">→</ui-button>
<calendar-entry start="2026-07-15" end="2026-07-15" label="Company holiday" color="neutral"></calendar-entry>
<calendar-entry start="2026-07-15T09:00" end="2026-07-15T09:30" label="Standup" color="info"></calendar-entry>
<calendar-entry start="2026-07-15T09:15" end="2026-07-15T10:00" label="Design review" color="primary" href="#review">
<span slot="detail">Walk through the new onboarding flow</span>
<span slot="location">Room A</span>
</calendar-entry>
</calendar-day>
Multi-day row
Any number of calendar-day elements can be lined up side by side in a
flex row with overflow-x: auto — today on the left, ascending dates to
the right. Once the row is wider than the viewport it scrolls horizontally instead of
wrapping. Each column's floor width comes from calendar-day's own
min-width property.
<div style="display: flex; gap: 1rem; overflow-x: auto; padding-bottom: 0.25rem;">
<calendar-day date="2026-08-01" min-width="20rem" style="flex-shrink: 0;" time-marker>...</calendar-day>
<calendar-day date="2026-08-02" min-width="20rem" style="flex-shrink: 0;" time-marker>...</calendar-day>
<calendar-day date="2026-08-03" min-width="20rem" style="flex-shrink: 0;" time-marker>...</calendar-day>
</div>
calendar-month
One month as a top-to-bottom day list. Weekends are highlighted, today is marked,
and overlapping declarative calendar-entry children stack into aligned
lanes rather than hiding one another. Named title/detail slots fill one line per
visible day, with overflow details hidden automatically. The actions
slot renders beside the month name for controls like month-navigation buttons.
import "@f-ewald/components/calendar-month.js";
<calendar-month year="2026" month="7">
<ui-button slot="actions" variant="secondary" size="sm" aria-label="Previous month">←</ui-button>
<ui-button slot="actions" variant="secondary" size="sm" aria-label="Next month">→</ui-button>
<calendar-entry start="2026-07-10" end="2026-07-18" label="Vacation" color="success">
<span slot="title">Vacation</span>
<span slot="detail">Out of office</span>
<span slot="detail">Road trip along the California coast with several scenic stops</span>
<span slot="footer">Return July 19 at 6 PM</span>
</calendar-entry>
<calendar-entry start="2026-07-15" end="2026-07-20" label="Conference" color="warning" href="#conf">
<span slot="detail">Talks and workshops</span>
<span slot="footer">Closing keynote · July 20</span>
</calendar-entry>
<calendar-entry start="2026-07-23" end="2026-07-23" label="Appointment" color="info">
<span slot="title">Dentist</span>
<span slot="detail">Hidden because this entry only spans one day</span>
<span slot="footer">Also hidden for a one-day entry</span>
</calendar-entry>
<calendar-entry start="2026-07-27" end="2026-07-28" label="Client lunch" color="neutral">
<span slot="location">Downtown bistro</span>
</calendar-entry>
<calendar-entry start="2026-07-05" end="2026-07-05" label="Team sync" color="neutral">
<span slot="location">Room 12</span>
</calendar-entry>
<calendar-entry start="2026-07-13" end="2026-07-13" label="Design review standup" color="primary">
<span slot="location">Building 4, 3rd floor conference room near the north elevators</span>
</calendar-entry>
<calendar-entry start="2026-07-12" end="2026-07-12" label="Budget check-in" color="danger">
<span slot="location">Finance office, 2nd floor, ask front desk for a visitor badge</span>
</calendar-entry>
<calendar-entry start="2026-07-24" end="2026-07-26" label="Team offsite" color="primary">
<span slot="detail">Bring hiking boots</span>
<span slot="location">Lakeside retreat center, Cabin building B, room 214</span>
</calendar-entry>
</calendar-month>
calendar-week
A Sunday-through-Saturday week as one shared hourly time grid — the seven-day
sibling of calendar-day. Multi-day/all-day entries span the days they
cover as a single continuous bar; timed entries stack side by side independently
per day. The actions slot renders beside the day headers for controls
like week-navigation buttons.
import "@f-ewald/components/calendar-week.js";
<calendar-week date="2026-07-15">
<ui-button slot="actions" variant="secondary" size="sm" aria-label="Previous week">←</ui-button>
<ui-button slot="actions" variant="secondary" size="sm" aria-label="Next week">→</ui-button>
<calendar-entry start="2026-07-13" end="2026-07-15" label="Offsite" color="primary" href="#offsite"></calendar-entry>
<calendar-entry start="2026-07-14T09:00" end="2026-07-14T09:30" label="Standup" color="info"></calendar-entry>
<calendar-entry start="2026-07-16T14:00" end="2026-07-16T15:00" label="Customer demo" color="success">
<span slot="location">Main conference room</span>
</calendar-entry>
</calendar-week>
calendar-year
A full year of calendar-month blocks generated from declarative
calendar-entry children — including entries that cross a month
boundary or extend past the edges of the displayed year.
import "@f-ewald/components/calendar-year.js";
<calendar-year year="2026">
<calendar-entry start="2026-01-28" end="2026-02-03" label="Offsite" color="primary" href="#offsite">
<span slot="detail">New York</span>
<span slot="detail">Team workshops</span>
<span slot="footer">Closing dinner Friday</span>
</calendar-entry>
<calendar-entry start="2026-03-05" end="2026-03-18" label="Product launch" color="success" href="#launch">
<span slot="detail">Coordinate the release across engineering, design, support, and marketing.</span>
<span slot="detail">Monitor adoption and production health throughout the rollout.</span>
<span slot="footer">Public launch · March 18 at 9 AM</span>
</calendar-entry>
<calendar-entry start="2026-07-10" end="2026-07-18" label="Vacation" color="success"></calendar-entry>
</calendar-year>
card-grid
Responsive auto-filling grid shell for link-card (or any card-shaped content) — wraps to a
new row once the container is too narrow for another 15rem column.
import "@f-ewald/components/card-grid.js";
import "@f-ewald/components/link-card.js";
<card-grid>
<link-card
heading="Grafana"
description="Metrics dashboards."
href="https://grafana.example.com"
logo="/logos/grafana.svg"
status="up"
></link-card>
<link-card heading="Plex" description="Media server." href="https://plex.example.com" status="up"></link-card>
</card-grid>
chat-message
One conversation entry — tool calls and thinking traces are dimmed, collapsible variants of the same component.
directory: . filename: notes.md content: | Autumn leaves falling...
import "@f-ewald/components/chat-message.js";
<chat-message role="user" author="Freddy" timestamp="2026-07-19T12:00:00Z">
Write notes.md containing a haiku.
</chat-message>
<chat-message role="agent" variant="tool" collapsible collapsed summary='file_write · {"filename": "notes.md"}'>
directory: .
filename: notes.md
</chat-message>
chevron-panel
A generic disclosure: a clickable header (slotted, so any markup goes in it) that expands/collapses a slotted body, with a chevron that rotates to reflect state.
Each category blends several weighted inputs — see the breakdown below.
import "@f-ewald/components/chevron-panel.js";
<chevron-panel>
<strong slot="headline">Why these scores?</strong>
<p>Each category blends several weighted inputs.</p>
</chevron-panel>
<script type="module">
document.querySelector("chevron-panel").addEventListener("toggle", (e) => {
console.log(e.detail.open);
});
</script>
code-diff
A compact, read-only diff viewer: a header bar for filename/stat,
then a numbered listing of lines — added, removed, or unchanged context,
each always carrying its own +/-/blank prefix so the diff reads
correctly even without color.
import "@f-ewald/components/code-diff.js";
<code-diff filename="cache.py" stat="−48 +1"></code-diff>
<script type="module">
document.querySelector("code-diff").lines = [
{ type: "del", text: "class CacheManager:" },
{ type: "del", text: "def __init__(self, ttl, maxsize): ..." },
{ type: "add", text: "@lru_cache(maxsize=1000)" },
{ type: "add", text: "def fetch(...): ..." },
];
</script>
comment-label
A code-comment-style eyebrow line for section chrome in terminal- or editor-themed
pages — a colored comment marker (## by default) before muted slotted
text. Set prefix="//" and italic for a closing quote line.
import "@f-ewald/components/comment-label.js";
<comment-label>the_whole_idea</comment-label>
<comment-label prefix="//" italic>the best code is the code never written.</comment-label>
confirm-dialog
Overlay confirmation dialog with Cancel/Confirm actions.
confirm: 0 · cancel: 0
import "@f-ewald/components/confirm-dialog.js";
<confirm-dialog open confirm-label="Delete" cancel-label="Cancel">
Are you sure you want to delete this item?
</confirm-dialog>
<!-- size="sm": one step below the default. -->
<confirm-dialog open confirm-label="Delete" cancel-label="Cancel" size="sm">
Are you sure you want to delete this item?
</confirm-dialog>
content-divider
A horizontal rule that separates two pieces of content not otherwise contained in a box
or frame. An optional centered label renders the "─── OR ───" pattern; both
forms reserve the same height, and the vertical spacing is tunable via
--component-divider-spacing.
First block of content.
Second block of content.
Third block, after a labeled divider.
import "@f-ewald/components/content-divider.js";
<content-divider></content-divider>
<content-divider label="OR"></content-divider>
<content-divider style="--component-divider-spacing: 1.5rem"></content-divider>
countdown-timer
Per-second ticking count-down timer.
import "@f-ewald/components/countdown-timer.js";
<countdown-timer until="2026-07-19T12:00:10Z" prefix="Retrying in "></countdown-timer>
<countdown-timer until="2026-07-19T12:00:10Z" format="compact" prefix="retrying in "></countdown-timer>
cron-schedule
Repeat-schedule picker: the trigger reads as plain English, the panel edits presets or
every individual cron field, and value is a standard 5-field cron expression.
Last change: —
import "@f-ewald/components/cron-schedule.js";
<cron-schedule label="Backup schedule" value="0 * * * *"></cron-schedule>
const schedule = document.querySelector("cron-schedule");
schedule.addEventListener("change", (e) => console.log(e.detail.value, schedule.description));
data-table
Generic table shell: a header from columns, one row per rows
entry, cell content from renderCell. Optional rowHref makes
whole rows clickable.
import "@f-ewald/components/data-table.js";
const table = document.querySelector("data-table");
table.columns = [
{ key: "title", label: "Title" },
{ key: "state", label: "State" },
];
table.rows = [
{ id: "tsk_1", title: "Write onboarding docs", state: "Backlog" },
{ id: "tsk_2", title: "Fix the login bug", state: "Done" },
];
table.rowHref = (row) => `#/tasks/${row.id}`;
distance-value
Inline distance display, switching units at sensible thresholds.
import "@f-ewald/components/distance-value.js";
<distance-value miles="5"></distance-value>
distribution-chart
KDE distribution curve for a named metric with value markers.
import "@f-ewald/components/distribution-chart.js";
<distribution-chart metric="sqft"></distribution-chart>
<script>
document.querySelector("distribution-chart").values = [{ label: "", value: 1450 }];
</script>
editable-text
Click-to-edit text: a display span that turns into an input/textarea on click.
Single-line (title)
Multiline (description)
import "@f-ewald/components/editable-text.js";
<editable-text value="Write the quarterly report" label="Title"></editable-text>
<editable-text multiline placeholder="Add a description…" label="Description"></editable-text>
empty-state
A centered placeholder for an empty list, an empty panel, or a zero-result
search. Optional leading icon slot, a heading and supporting
line, and an actions slot for a call to action. Every optional
part collapses when absent. size="sm" fits a small panel or
sidebar; md is the full-page default.
import "@f-ewald/components/empty-state.js";
<empty-state heading="No results found" description="Try adjusting your search.">
<span slot="icon">...</span>
<ui-button slot="actions" variant="primary">Clear filters</ui-button>
</empty-state>
<empty-state size="sm" heading="No pinned items"></empty-state>
form-actions
Form footer button bar with a fixed order: primary (submit) is always rightmost, secondary (cancel) to its left, and an optional destructive action pinned far left — regardless of the order the buttons are authored in.
import "@f-ewald/components/form-actions.js";
<form-actions>
<ui-button slot="start" variant="danger">Delete</ui-button>
<ui-button slot="secondary" variant="secondary">Cancel</ui-button>
<ui-button slot="primary" type="submit" variant="primary">Save</ui-button>
</form-actions>
form-field
Per-field wrapper: label, slotted control, and an optional hint or error message, repeated once per field throughout a form.
import "@f-ewald/components/form-field.js";
<form-field label="Task state" hint="Only affects your own view">
<form-select></form-select>
</form-field>
<form-field label="Terms" required error="You must accept to continue">
<ui-checkbox label="I agree to the terms"></ui-checkbox>
</form-field>
<form-field label="Website">
<input type="url" placeholder="https://example.com" />
</form-field>
<form-field floating-label label="Email">
<input type="email" placeholder="name@example.com" />
</form-field>
<form-field floating-label label="Language">
<autocomplete-input clearable placeholder="Start typing…"></autocomplete-input>
</form-field>
form-select
Styled dropdown select: a trigger button opening a listbox popover, firing
change with { value }. Enable
searchable for case-insensitive infix filtering while still
requiring an explicit option selection.
Searchable (try gress or VIEW)
Shrink-to-fit override — host set to display: inline-block
import "@f-ewald/components/form-select.js";
import {
iconArrowPath,
iconCheckCircle,
iconEye,
iconListBullet,
} from "@f-ewald/components/icons.js";
const select = document.querySelector("form-select");
select.options = [
{ value: "backlog", label: "Backlog", icon: iconListBullet(14), iconSize: 14 },
{ value: "open", label: "Open" },
{ value: "in_progress", label: "In progress", icon: iconArrowPath(16), iconSize: 16 },
{ value: "review", label: "Needs review", icon: iconEye(18), iconSize: 18 },
{ value: "done", label: "Done", icon: iconCheckCircle(16), iconSize: 16 },
];
select.value = "open";
select.searchable = true;
select.addEventListener("change", (e) => console.log(e.detail.value));
frame-box
A titled frame around a slot — a gray border with a small uppercase, muted label overlapping the top edge. Generic; the label text is up to the consumer.
Framed content goes here.
import "@f-ewald/components/frame-box.js";
<frame-box label="Debug">
Framed content goes here.
</frame-box>
kanban-board
A configurable board of columns and cards where a card's column is its state.
Drag a card between columns or reorder within one; or open a card (click / Enter) to see
its details and change its state. Keyboard: focus a card, Space to pick up,
arrows to move, Space to drop, Esc to cancel. After a move, the
card briefly flashes a warm highlight so you can see where it landed.
Drag a card, or open one to change its state.
import "@f-ewald/components/kanban-board.js";
const board = document.querySelector("kanban-board");
board.columns = [
{
id: "todo",
title: "To Do",
cards: [
{
id: "c1",
ticket: "PROJ-142",
title: "Wire up auth callback",
description: "Handle the OAuth redirect and persist the session.",
createdAt: "2026-07-18T09:12:00Z",
updatedAt: "2026-07-21T14:03:00Z",
},
],
},
{ id: "doing", title: "In Progress", cards: [] },
{ id: "done", title: "Done", cards: [] },
];
// A card's column is its state; drag-drop, keyboard, and the detail
// popover all emit the same event.
board.addEventListener("card-move", (e) => {
const { cardId, fromColumnId, toColumnId, toIndex } = e.detail;
});
board.addEventListener("card-open", (e) => console.log(e.detail.cardId));
kbd-hint
Platform-aware keyboard shortcut hints rendered as compact keycaps. Use
Mod for Command on macOS and Control elsewhere, or override
platform for deterministic output.
import "@f-ewald/components/kbd-hint.js";
<kbd-hint keys="Mod+K"></kbd-hint>
<kbd-hint keys="Mod+Shift+Enter" platform="mac"></kbd-hint>
link-card
A single linked-resource tile — logo (or an initial-letter fallback), heading, optional description, and
an optional reachability status dot. Renders as a link when href is set, a plain tile
otherwise. Normally laid out inside card-grid.
import "@f-ewald/components/link-card.js";
<link-card
heading="Grafana"
description="Metrics dashboards."
href="https://grafana.example.com"
logo="/logos/grafana.svg"
status="up"
></link-card>
<link-card heading="Portainer" description="Container management UI." href="https://portainer.example.com" status="down"></link-card>
<link-card heading="Backup Server" description="Nightly restic snapshots." status="checking"></link-card>
<link-card heading="Internal Notes" description="No link — informational tile only."></link-card>
<link-card heading="Uptime Kuma" description="Logo URL fails to load." logo="https://broken.example/logo.png"></link-card>
live-timer
Per-second ticking count-up timer.
import "@f-ewald/components/live-timer.js";
<live-timer since="2026-07-19T12:00:00Z" prefix="Sleeping for "></live-timer>
<live-timer since="2026-07-19T12:00:00Z" format="compact" prefix="running for "></live-timer>
load-more
Click-to-load button for either end of a list. Fully property-driven: the consumer
sets loading during a fetch and exhausted once nothing's
left; a few clicks here reach the exhausted state.
- Item 1
- Item 2
- Item 3
import "@f-ewald/components/load-more.js";
<load-more direction="top" label="Load older"></load-more>
<load-more></load-more>
loading-dots
Three dots that bounce one after another — a lightweight, indeterminate
"working" or "typing" indicator. Presentational and property-driven; the
size is sm, md, or lg, and it rests
(no bounce) under prefers-reduced-motion.
import "@f-ewald/components/loading-dots.js";
<loading-dots></loading-dots>
<loading-dots size="sm"></loading-dots>
<loading-dots size="lg" label="Sending message"></loading-dots>
loading-spinner
Indeterminate circular spinner — a rotating arc over a faint track, in the
style of a browser page-load indicator. Presentational and property-driven; the
size is sm, md, or lg, and it
becomes a static ring under prefers-reduced-motion.
import "@f-ewald/components/loading-spinner.js";
<loading-spinner></loading-spinner>
<loading-spinner size="sm"></loading-spinner>
<loading-spinner size="lg" label="Loading results"></loading-spinner>
map-circle
Plain circular marker — a light-to-dark gradient fill with a white outer ring, no point/tail. Use it as an individual DOM marker with optional badge content, or rasterize a small instance once for a dense point layer. No mapping-library dependency.
Configurable ring-width and size
Configurable ring-opacity
import "@f-ewald/components/map-circle.js";
<map-circle color="#6b7280"></map-circle>
<map-circle color="#0099D8" size="14" ring-width="3"></map-circle>
<map-circle color="#1a73e8" size="24" ring-width="5" highlighted>1</map-circle>
<map-circle color="#3b82f6" ring-opacity="0.35"></map-circle>
map-pin
Circular "Apple Maps"-style pin — a light-to-dark gradient fill with a slight point at the bottom. No mapping-library dependency; the consumer positions it (e.g. via a mapboxgl.Marker).
Configurable ring-opacity
import "@f-ewald/components/map-pin.js";
<map-pin color="#1a73e8" size="30">3</map-pin>
<map-pin color="#22c55e" size="26" highlighted>🏠</map-pin>
<map-pin color="#1a73e8" size="30" ring-opacity="0.35">4</map-pin>
mapbox-map
Thin wrapper around a mapboxgl.Map — construction, access token, style
loading/switching, and container resizing only. No layer registry, no click-handler
system, no markers/popups; a consumer registers its own sources/layers/handlers on
the mapboxgl.Map instance handed back via map-ready. Needs a
VITE_MAPBOX_TOKEN env var to render live in this playground.
import "@f-ewald/components/mapbox-map.js";
<mapbox-map
access-token="pk.your-token"
style-url="mapbox://styles/mapbox/light-v11"
></mapbox-map>
<script type="module">
document.querySelector("mapbox-map").addEventListener("map-ready", (e) => {
const map = e.detail.map; // the underlying mapboxgl.Map
map.addSource("mine", { type: "geojson", data: "/mine.geojson" });
map.addLayer({ id: "mine", type: "circle", source: "mine", paint: { "circle-color": "#4f46e5" } });
});
</script>
markdown-editor
GitHub-style Write/Preview markdown editor built on tab-bar. Leading YAML
front matter is detected and rendered as a key-value table above the previewed body.
import "@f-ewald/components/markdown-editor.js";
const el = document.querySelector("markdown-editor");
el.value = `---
title: Weekly status
author: Ada Lovelace
tags: [engineering, updates]
---
# Weekly status
Some **markdown** content here.`;
el.addEventListener("input", (event) => console.log(event.detail.value));
markdown-view
Renders a markdown string as sanitized, styled HTML — headings, lists, code, tables, blockquotes, and links, with wide content scrolling in its own container.
import "@f-ewald/components/markdown-view.js";
const el = document.querySelector("markdown-view");
el.markdown = `## Release notes
- Added **markdown-view**
- Fixed a table alignment bug
\`\`\`ts
const x = 1;
\`\`\`
| Component | Status |
| --- | --- |
| markdown-view | New |
See the [changelog](#markdown-view) for details.`;
modal-dialog
Generic centered overlay shell — header, close button, and an arbitrary slotted body.
No baked-in actions (unlike confirm-dialog); the consumer supplies the
body content and its own footer buttons if any.
dismissible is set.
A full-viewport dialog for immersive content — covers the entire screen edge-to-edge with no border-radius or overlay padding, and scrolls when the body overflows.
Paragraph 1 of filler content to demonstrate scrolling.
Paragraph 2 of filler content to demonstrate scrolling.
Paragraph 3 of filler content to demonstrate scrolling.
Paragraph 4 of filler content to demonstrate scrolling.
Paragraph 5 of filler content to demonstrate scrolling.
Paragraph 6 of filler content to demonstrate scrolling.
Paragraph 7 of filler content to demonstrate scrolling.
Paragraph 8 of filler content to demonstrate scrolling.
Paragraph 9 of filler content to demonstrate scrolling.
Paragraph 10 of filler content to demonstrate scrolling.
import "@f-ewald/components/modal-dialog.js";
<modal-dialog open heading="Changelog" dismissible>
Dialog body content goes here.
</modal-dialog>
<modal-dialog open heading="Wide content" size="lg">
A 60rem-wide dialog for content like tables.
</modal-dialog>
<modal-dialog open heading="Full screen" size="fullscreen">
Covers the entire viewport and scrolls when content overflows.
</modal-dialog>
multi-select
Form-associated multi-selection: a compact trigger opening a
multi-selectable listbox popover, or a persistently visible
variant="list" surface with no popover. Chosen values submit
as repeated name=value entries, matching a native
<select multiple>
(new FormData(form).getAll(name)); add
show-chips to also render them as removable chips below the
trigger. Enable searchable for case-insensitive infix
filtering that never becomes a value.
Dropdown, icons, max="3", two preselected values,
show-chips
Searchable dropdown (try re or EE)
Persistent list — variant="list" visible-rows="4"
Searchable list — a Gray option is disabled
Shrink-to-fit override — host set to display: inline-block
import "@f-ewald/components/multi-select.js";
<form>
<multi-select name="colors" label="Colors" required></multi-select>
</form>
<script type="module">
const ms = document.querySelector("multi-select");
ms.options = [
{ value: "red", label: "Red" },
{ value: "green", label: "Green" },
{ value: "blue", label: "Blue" },
];
ms.values = ["red"];
ms.searchable = true; // opt-in infix filtering
// ms.variant = "list"; ms.visibleRows = 4; // persistent list instead
ms.addEventListener("change", (e) => console.log(e.detail.values));
</script>
page-header
Page title block: an optional breadcrumb trail, the heading with its page actions beside it, and an optional description of what the page is for underneath. The breadcrumb row reserves no space when it's empty, and the actions stay on the title row, so they don't move when the description wraps.
import "@f-ewald/components/page-header.js";
<page-header heading="Team members" description="Everyone with access to this workspace.">
<nav slot="breadcrumb" aria-label="Breadcrumb">Home / Settings / Members</nav>
<ui-button slot="actions" variant="primary">Invite</ui-button>
</page-header>
percent-bar-chart
Bar chart of labeled rows: horizontal bars or vertical columns, showing 0-100 percentages or arbitrary absolute values.
mode="value" + orientation="vertical", with a custom
valueFormat for currency.
import "@f-ewald/components/percent-bar-chart.js";
const chart = document.querySelector("percent-bar-chart");
chart.groups = [
{ key: "a", label: "White", value: 45.2, color: "#4f46e5" },
{ key: "b", label: "Asian", value: 28.1, color: "#0d9488" },
];
// Absolute values instead of percentages, as vertical columns:
chart.mode = "value";
chart.orientation = "vertical";
chart.valueFormat = (value) => `$${value.toLocaleString()}`;
photo-gallery
Responsive carousel with accessible controls, autoplay, keyboard navigation, swipe gestures, and declarative image metadata.
Showing image 1 of 3
import "@f-ewald/components/photo-gallery.js";
<photo-gallery delay="5000" show-counter show-indicators>
<gallery-item
src="/photos/coast.jpg"
alt="Rocky California coastline"
caption="California coast"
>
<gallery-item-variant
media="(max-width: 640px)"
srcset="/photos/coast-portrait.jpg"
></gallery-item-variant>
</gallery-item>
<gallery-item src="/photos/bridge.jpg" alt="Golden Gate Bridge"></gallery-item>
</photo-gallery>
popover-panel
Anchored floating popover — like slide-panel, but positioned relative to
its trigger instead of docked to the screen edge. Closes on outside click or Escape.
import "@f-ewald/components/popover-panel.js";
<div style="position: relative; display: inline-block;">
<button>New task</button>
<popover-panel open heading="New task">
<a slot="actions" href="#/tasks/new">Full page ↗</a>
Popover body content goes here.
</popover-panel>
</div>
<!-- Screen-centered modal variant -->
<popover-panel centered open heading="New task">
Popover body content goes here.
</popover-panel>
price-history-chart
D3-powered SVG line chart for a property's price history.
import "@f-ewald/components/price-history-chart.js";
const el = document.querySelector("price-history-chart");
el.history = [
{ date: "2023-01-01", price: 620000, eventType: "Listed" },
{ date: "2023-06-01", price: 645000, eventType: "Price change" },
{ date: "2024-02-01", price: 680000, eventType: "Sold" },
];
progress-bar
Determinate horizontal progress indicator for a page-level "step X of Y" bar. An
optional label renders as plain text to the right of the bar rather than
a percent inside it.
Custom fill and track color
import "@f-ewald/components/progress-bar.js";
<progress-bar value="3" max="14" label="Question 3 out of 14"></progress-bar>
<progress-bar value="7" max="10" color="#dc2626" track-color="#fecaca"></progress-bar>
radio-cards
Single-select cards with a label and optional description.
Selected:
layout="vertical"
layout="horizontal" with hide-input (head-to-head style)
layout="mixed" (default) with a fullWidth option
import "@f-ewald/components/radio-cards.js";
const el = document.querySelector("radio-cards");
el.options = [
{ value: "simple", label: "Simple", description: "Quick-ranking view" },
{ value: "detailed", label: "Detailed", description: "Every section and layer" },
];
el.value = "simple";
el.addEventListener("change", (e) => console.log(e.detail.value));
<!-- layout="horizontal" + hide-input: two side-by-side cards with no visible radio dot -->
<radio-cards layout="horizontal" hide-input></radio-cards>
<!-- layout="mixed" (default) + a fullWidth option (e.g. a "tie" choice below two side-by-side cards) -->
<script type="module">
document.querySelector("#mixed-full").options = [
{ value: "schools", label: "Schools", description: "School quality" },
{ value: "nightlife", label: "Nightlife", description: "Bars and restaurants" },
{ value: "tie", label: "Equally important", fullWidth: true },
];
</script>
radio-pills
Single-select compact pills for many short, same-shaped options.
Selected:
import "@f-ewald/components/radio-pills.js";
const el = document.querySelector("radio-pills");
el.options = [
{ value: "light", label: "Light" },
{ value: "streets", label: "Streets" },
{ value: "satellite", label: "Satellite" },
];
el.value = "light";
el.addEventListener("change", (e) => console.log(e.detail.value));
range-slider
Form-associated numeric slider, restyled from a native
<input type="range"> to match this package's track/fill look. No
built-in label — wrap in form-field, or render a value readout next to
it as shown here.
import "@f-ewald/components/range-slider.js";
<range-slider min="100" max="5000" step="50" value="1000"></range-slider>
<script type="module">
document.querySelector("range-slider").addEventListener("input", (e) => {
console.log(e.detail.value);
});
</script>
relative-time
Inline relative-time display (e.g. "3 hours ago").
import "@f-ewald/components/relative-time.js";
<relative-time datetime="2026-07-17T07:00:00Z"></relative-time>
roman-numeral
Converts an integer to a roman numeral inline.
import "@f-ewald/components/roman-numeral.js";
<roman-numeral value="2004"></roman-numeral>
scroll-dots
A vertical section navigator: one dot per section, the active one an elongated bar.
Controlled — it reads no scroll position, so the consumer sets active and
moves the page on dot-select. Dots use the same gradient as
map-circle, from a single base color.
Active: 1
The last two dots are muted. Click any dot, or use the buttons.
import "@f-ewald/components/scroll-dots.js";
<scroll-dots label="Journey stops"></scroll-dots>
<script type="module">
const rail = document.querySelector("scroll-dots");
rail.items = ["Intro", "Freiburg", "Berkeley", { label: "Credits", muted: true }];
rail.active = 0;
rail.addEventListener("dot-select", (e) => {
rail.active = e.detail.index;
sections[e.detail.index].scrollIntoView({ behavior: "smooth", block: "start" });
});
</script>
scroll-to-bottom
Pill button: appears once scrolled more than threshold px away from the
bottom edge, and scrolls back down on click. With no target (below), it's
fixed to the whole page; point target at a container (and give that
container position: relative) to have it float inside that container's
own scrollport instead.
Scroll this page down to see the window-target button appear bottom-right.
Line 1 of a scrollable log.
Line 2 of a scrollable log.
Line 3 of a scrollable log.
Line 4 of a scrollable log.
Line 5 of a scrollable log.
Line 6 of a scrollable log.
Line 7 of a scrollable log.
Line 8 of a scrollable log.
Line 9 of a scrollable log.
Line 10 of a scrollable log.
import "@f-ewald/components/scroll-to-bottom.js";
<scroll-to-bottom></scroll-to-bottom>
<!-- Floats inside its own scrollport instead of the whole page: -->
<div id="log" style="position: relative; overflow-y: auto; height: 10rem">
...
<scroll-to-bottom threshold="20"></scroll-to-bottom>
</div>
<script type="module">
document.querySelector('scroll-to-bottom').target = document.querySelector('#log');
</script>
scroll-to-top
Pill button: appears once scrolled more than threshold px away from the
top edge, and scrolls back up on click. With no target (below), it's
fixed to the whole page; point target at a container (and give that
container position: relative) to have it float inside that container's
own scrollport instead.
Scroll this page down to see the window-target button appear bottom-right (offset above the scroll-to-bottom button, above).
Line 1 of a scrollable log.
Line 2 of a scrollable log.
Line 3 of a scrollable log.
Line 4 of a scrollable log.
Line 5 of a scrollable log.
Line 6 of a scrollable log.
Line 7 of a scrollable log.
Line 8 of a scrollable log.
Line 9 of a scrollable log.
Line 10 of a scrollable log.
import "@f-ewald/components/scroll-to-top.js";
<scroll-to-top></scroll-to-top>
skip-link
The classic "Skip to main content" accessibility affordance — a real
<a> that stays visually hidden until it receives keyboard
focus, then pins itself to the top-left as a solid high-contrast block. It
only appears on keyboard focus: press Tab after clicking into the
box below to reveal it. Works with no JavaScript beyond the element upgrade.
Main content target. Tab into this area and the skip links above appear at the top-left of the viewport.
import "@f-ewald/components/skip-link.js";
<skip-link href="#main"></skip-link>
<skip-link href="#results" label="Jump to results"></skip-link>
slide-panel
Sliding panel shell with header chrome and a close button.
import "@f-ewald/components/slide-panel.js";
<slide-panel open heading="Property details">
Panel body content goes here.
</slide-panel>
spec-list
A key/value specification sheet — one record's attributes as a muted, wide-tracked
key column against a value column, separated by hairline rules (uppercased under
themes that set --ui-label-transform). Feed it
items, or slot your own <dt>/<dd> groups when a
value needs a link or other markup. Distinct from data-table, which is
tabular.
Data-driven
Slotted, no dividers, stacked
Slot bare dt/dd pairs and they inherit the component's own
key/value styling — no page CSS needed.
import "@f-ewald/components/spec-list.js";
<spec-list caption="Specifications"></spec-list>
<script type="module">
document.querySelector("spec-list").items = [
{ label: "Material", value: "Anodized aluminum" },
{ label: "Weight", value: "1.2 kg" },
{ label: "Warranty", value: "2 years" },
];
</script>
<!-- Or slot your own markup when a value needs a link or a pill. Slotted
`dt`/`dd` pairs pick up the component's own styling.
`dividers` defaults to true; turn it off via the property, since a
`dividers="false"` attribute still parses as a truthy boolean. -->
<spec-list layout="stacked" id="sheet">
<dt>Homepage</dt>
<dd><a href="https://example.com">example.com</a></dd>
</spec-list>
<script type="module">
document.getElementById("sheet").dividers = false;
</script>
split-hero
Full-viewport split layout for a sign-in/sign-up page: a user-supplied photo
(src) fills one half, the default slot — typically a form — fills
the other. Resize below 48rem, or toggle the photo off, to see it collapse to a
single column.
import "@f-ewald/components/split-hero.js";
import "@f-ewald/components/form-field.js";
import "@f-ewald/components/ui-button.js";
<split-hero src="/photos/coast.jpg" alt="Coastal road" style="height: 100vh">
<form>
<h1>Sign in</h1>
<form-field label="Email"><input type="email" name="email" /></form-field>
<form-field label="Password"><input type="password" name="password" /></form-field>
<ui-button type="submit" variant="primary">Sign in</ui-button>
</form>
</split-hero>
stat-meter
Compact labeled meter for a single percentage reading — CPU/memory usage in a
dashboard header, for example. percent accepts null for
"no reading yet", rendering an empty bar and a "—" instead of "0%".
Custom fill color
Custom track color
The default track uses --ui-surface-muted, which reads as barely-there against
a gray section background. track-color (or the --track-color custom
property) overrides it per instance.
import "@f-ewald/components/stat-meter.js";
<stat-meter label="CPU" percent="42"></stat-meter>
<stat-meter label="MEM" percent="76"></stat-meter>
<stat-meter label="I/O"></stat-meter> <!-- percent unset -> null -> renders "—" -->
<stat-meter label="GPU" percent="88" color="#dc2626"></stat-meter>
<stat-meter label="DISK" percent="60" track-color="#cbd5e1"></stat-meter>
stat-strip
A headless row of headline stats — one large accent-colored figure plus a muted
caption per items entry, wrapping across lines on narrow viewports.
Unlike stat-meter it renders no fill bar; it's for marketing/benchmark
summary rows, not a live percentage reading.
import "@f-ewald/components/stat-strip.js";
<stat-strip></stat-strip>
<script type="module">
document.querySelector("stat-strip").items = [
{ value: "54%", label: "less code" },
{ value: "22%", label: "fewer tokens" },
{ value: "100%", label: "safety kept" },
];
</script>
status-pill
Colored status pill, optionally with a spinning icon.
import "@f-ewald/components/status-pill.js";
<status-pill label="Running" color="primary" spinner></status-pill>
<status-pill label="Blocked" color="danger"></status-pill>
step-ladder
A flat ordered ladder of fallback steps: a zero-padded ordinal, a bold title, and a
muted description per items entry, separated by hairline rules. Feed it
items, or slot bare <li> rungs when a step needs richer
markup — slotted content takes precedence.
import "@f-ewald/components/step-ladder.js";
<step-ladder></step-ladder>
<script type="module">
document.querySelector("step-ladder").items = [
{ title: "Does this need to exist?", description: "Speculative need = skip it." },
{ title: "Already in this codebase?", description: "Reuse the helper that already lives here." },
{ title: "Does the standard library do it?", description: "Use it." },
];
</script>
tab-bar
WAI-ARIA tabs: a strip of tab-item panels, switched by click or the
arrow/Home/End keys. The active tab's underline uses the primary color; a shared
border line runs beneath the whole strip for the inactive state.
Project overview content goes here.
Recent activity content goes here.
Settings content goes here.
Active tab: overview
import "@f-ewald/components/tab-bar.js";
import "@f-ewald/components/tab-item.js";
<tab-bar label="Project sections">
<tab-item label="Overview" value="overview" selected>Overview content</tab-item>
<tab-item label="Activity" value="activity">Activity content</tab-item>
<tab-item label="Settings" value="settings">Settings content</tab-item>
</tab-bar>
<script type="module">
document.querySelector("tab-bar").addEventListener("change", (event) => {
console.log(event.detail.value);
});
</script>
terminal-block
A headless, always-dark terminal transcript shell for install/usage instructions.
Each lines entry is a "prompt" (accent-marked command),
"comment" (dim italic), or "output" (plain) line, rendered
in order.
import "@f-ewald/components/terminal-block.js";
<terminal-block></terminal-block>
<script type="module">
document.querySelector("terminal-block").lines = [
{ type: "comment", text: "# Claude Code" },
{ type: "prompt", text: "/plugin marketplace add example/example" },
{ type: "prompt", text: "/plugin install example@example" },
];
</script>
text-area
Plain multi-line text field — a thin, tokenized wrapper around a native
<textarea>. Not a rich editor; use readonly to display
pre-formatted text the user can still select and copy.
import "@f-ewald/components/text-area.js";
<text-area placeholder="Describe the issue…" rows="4"></text-area>
<text-area readonly value="Error code: 429 - No deployments available for selected model."></text-area>
tile-grid
Generic grid shell: one bordered tile per items entry, content from
renderTile. Optional itemHref makes whole tiles clickable.
Optional file-icon prefixes each tile with a decorative "document" icon.
import "@f-ewald/components/tile-grid.js";
const grid = document.querySelector("tile-grid");
grid.items = [
{ name: "notes.txt" },
{ name: "photo.jpg" },
];
grid.renderTile = (item) => item.name;
grid.fileIcon = true; // or the `file-icon` attribute
timeline-container
A vertical timeline. Each timeline-entry is a dot on the line with an
optional headline, a relative time, and freely nested content (avatars, pills, code).
v1.4.0 is rolling out to production.
#412 opened.
import "@f-ewald/components/timeline-container.js";
import "@f-ewald/components/timeline-entry.js";
<timeline-container>
<timeline-entry datetime="2026-07-23T09:00:00Z">
<span slot="headline">Deployment started</span>
Release v1.4.0 is rolling out.
</timeline-entry>
<timeline-entry datetime="2026-07-23T08:45:00Z" color="success">
<span slot="headline">Review approved</span>
<status-pill label="In Review" color="info"></status-pill>
</timeline-entry>
<timeline-entry running>
<span slot="headline">Deploying</span>
Uploading build artifacts…
</timeline-entry>
</timeline-container>
layout="alternating"
A centered line with the label on one side and the body on the other, swapping every
second entry — for a presentation timeline rather than an event log. Entries use
label (or a slotted label) instead of a wall-clock
datetime; one here has neither, so that side stays empty.
<timeline-container layout="alternating">
<timeline-entry label="1987">
<span slot="headline">Where it started</span>
A first stop, with the label on the left.
</timeline-entry>
<timeline-entry label="2004">
<span slot="headline">The question</span>
The second entry mirrors the first.
</timeline-entry>
</timeline-container>
toast-notification
Fixed-position stack of dismissible notifications. Each timed toast below shows a clockwise-filling countdown ring in place of its ✕ — hover or Tab to it to swap back to the ✕ and pause the countdown; move away or Tab off to resume from where it left off.
import "@f-ewald/components/toast-notification.js";
import { notifySuccess } from "@f-ewald/components/toast-notification.js";
<toast-notification></toast-notification>
<script>notifySuccess("Saved!", "Your changes are now live.");</script>
tree-view
Generic recursive tree shell: one row per nodes entry, content from
renderNode. A node with a children array is a folder
(click toggles expand/collapse); otherwise it's a leaf (click fires
node-click). Add the lines attribute for classic
file-tree connector guides.
With connector lines (lines)
import "@f-ewald/components/tree-view.js";
const tree = document.querySelector("tree-view");
tree.nodes = [
{
id: "docs",
label: "docs",
children: [{ id: "fil_1", label: "notes.txt", data: { id: "fil_1" } }],
},
{ id: "fil_2", label: "readme.md", data: { id: "fil_2" } },
];
tree.renderNode = (node) => node.label;
tree.lines = true; // optional: draw connector guides
tree.addEventListener("node-click", (e) => console.log(e.detail));
ui-admonition
Bordered, rounded callout card for an inline notice with an optional call to action.
Unlike status-banner (a borderless full-width bar for a persistent
app-level condition), this is meant to sit inline within a page's content column.
import "@f-ewald/components/ui-admonition.js";
import "@f-ewald/components/ui-button.js";
<ui-admonition variant="info">
These are balanced defaults — take the quiz to personalize them.
<ui-button slot="actions" variant="primary">Take the quiz</ui-button>
</ui-admonition>
ui-checkbox
Form-associated boolean checkbox, usable standalone or inside a native
<form>.
import "@f-ewald/components/ui-checkbox.js";
<ui-checkbox label="Subscribe to updates"></ui-checkbox>
<ui-checkbox name="terms" label="I agree to the terms" required></ui-checkbox>
<!-- Slot the label when it needs its own markup; it overrides the property -->
<ui-checkbox name="beta">Enable <strong>beta</strong> features</ui-checkbox>
<!-- .icon is set programmatically (a pre-rendered TemplateResult), not an attribute -->
<ui-checkbox label="Show list view"></ui-checkbox>
user-avatar
Circular avatar — shows an image, falling back to an initial, falling back further to a generic icon.
import "@f-ewald/components/user-avatar.js";
<user-avatar src="https://example.com/photo.jpg" name="Freddy" size="40"></user-avatar>
<user-avatar name="Freddy" size="sm"></user-avatar>
video-player
Same transport bar as audio-player — play/pause, elapsed/total time, seek
bar, mute + volume — plus a fullscreen toggle, around a native
<video> element.
events:
import "@f-ewald/components/video-player.js";
<video-player src="/clip.mp4" poster="/clip-poster.jpg" label="Episode 12"></video-player>
vote-control
Up/down vote widget with a live score readout — the "vote an entry toward a promotion
threshold" pattern. One member casts a single vote that can be switched or withdrawn at
any time; an optional target renders a thin progress meter toward the
threshold. vote reflects the pressed button, and vote-change
fires with { vote, value }.
Last event:
import "@f-ewald/components/vote-control.js";
<vote-control value="7" target="10" label="Vote for this entry"></vote-control>
<vote-control orientation="horizontal" value="42" target="50"></vote-control>
<vote-control value="3" disabled></vote-control>
weight-bar-chart
Sorted horizontal bar chart of normalized weights.
import "@f-ewald/components/weight-bar-chart.js";
document.querySelector("weight-bar-chart").items = [
{ id: "price", label: "Price", value: 0.4 },
{ id: "schools", label: "Schools", value: 0.35 },
{ id: "commute", label: "Commute", value: 0.25 },
];
window-chrome
Sticky editor/terminal-style chrome bar: three decorative traffic-light dots, a
filename-style label, and a right-aligned actions slot for
controls such as a theme toggle.
Page content scrolls beneath the sticky bar above.
import "@f-ewald/components/window-chrome.js";
import "@f-ewald/components/icon-button.js";
<window-chrome label="~/product — README.md">
<icon-button slot="actions" label="Toggle theme"></icon-button>
</window-chrome>
comment-composer
One-line field that expands into a textarea with bottom-right Cancel/Submit buttons on focus. Submitting clears and collapses it; Escape cancels; Cmd/Ctrl+Enter submits.
last submitted: (none)