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.

Delete Create
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.

Acme Admin

Workspace

Dashboard Members Schedule
Signed in as Fred
Create
This dialog renders above the sidebar and top bar even while the sidebar overlay is open — the shell's own chrome stays under z-index 100.

Selected 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>

app-sidebar

Slot-based navigation for the shell's sidebar slot. Toggle "Collapse" for rail mode: icons stay, labels hide, and the active item keeps its highlight. Each label reads the --app-sidebar-label custom property so the sidebar can hide it.

Acme Admin

Workspace

Dashboard Members Schedule
Signed in as Fred
import "@f-ewald/components/app-sidebar.js";

<app-sidebar collapsed>
  <a href="/dashboard" aria-current="page" aria-label="Dashboard">
    <!-- icon -->
    <span style="display: var(--app-sidebar-label, inline)">Dashboard</span>
  </a>
</app-sidebar>

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).

Started Job queued. Progress Step 1 complete. Progress Step 2 complete.
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>

button-group

Single-select segmented control — a strip of buttons joined into one shape, with the current choice visually pressed.

Selected:

Selected:

import "@f-ewald/components/button-group.js";

const el = document.querySelector("button-group");
el.options = [
  { value: "list", label: "List" },
  { value: "kanban", label: "Kanban" },
];
el.value = "list";
el.addEventListener("change", (e) => console.log(e.detail.value));

// Icon-only: labels stay the accessible name, hidden visually.
const iconOnly = document.querySelector("#button-group-icon-only");
iconOnly.options = [
  { value: "list", label: "List", icon: iconListBullet() },
  { value: "kanban", label: "Kanban", icon: iconSquares2x2() },
];
iconOnly.value = "list";

// size="sm": one step below the default.
document.querySelector("#button-group-sm").options = el.options;
document.querySelector("#button-group-sm").value = "list";

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.

Walk through the new onboarding flow Room A
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">&larr;</ui-button>
  <ui-button slot="actions" variant="secondary" size="sm" aria-label="Next day">&rarr;</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.

Days shown 3 +
Column min-width 20rem +
<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.

Vacation Out of office Road trip along the California coast with several scenic stops Return July 19 at 6 PM Talks and workshops Closing keynote · July 20 Dentist Bring insurance card Appointment complete Downtown bistro Room 12 Building 4, 3rd floor conference room near the north elevators Finance office, 2nd floor, ask front desk for a visitor badge Bring hiking boots Lakeside retreat center, Cabin building B, room 214
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">&larr;</ui-button>
  <ui-button slot="actions" variant="secondary" size="sm" aria-label="Next month">&rarr;</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.

Main conference room
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">&larr;</ui-button>
  <ui-button slot="actions" variant="secondary" size="sm" aria-label="Next week">&rarr;</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.

Offsite New York Team workshops Closing dinner Friday Product launch Coordinate the release across engineering, design, support, and marketing. Monitor adoption and production health throughout the rollout. Public launch · March 18 at 9 AM
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.

Write notes.md containing a haiku. Done — I wrote a haiku about the changing seasons to notes.md.
directory: .
filename: notes.md
content: |
  Autumn leaves falling...
I should pick a seasonal image that reads naturally in 5-7-5 syllables. Delegated to Researcher

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.

Why these scores?

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-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)

import "@f-ewald/components/comment-composer.js";

<comment-composer placeholder="Add a comment…"></comment-composer>

document.querySelector("comment-composer")
  .addEventListener("submit", (e) => post(e.detail.value));

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.

the_whole_idea the best code is the code never written.
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

Are you sure you want to delete this item? This cannot be undone. Are you sure you want to delete this item? This cannot be undone.
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.

Seconds:
Compact:
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.

Clear filters
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.

Save Cancel Delete

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>

fullscreen-button

Toggles fullscreen for the page, or for a given target element. The icon and label follow the real fullscreen state, so leaving with Escape still flips them back.

Whole page
This panel only

Not fullscreen.

import "@f-ewald/components/fullscreen-button.js";

<fullscreen-button></fullscreen-button>

<script type="module">
  const button = document.querySelector("fullscreen-button");
  button.target = document.querySelector("#deck"); // omit for the whole page
  button.addEventListener("fullscreen-change", (e) => console.log(e.detail.active));
</script>

icon-button

A borderless button wrapping a passed-in icon, with a rounded hover-highlight background — a low-emphasis affordance next to content it acts on.

import "@f-ewald/components/icon-button.js";
import { iconPencil } from "@f-ewald/components/icons.js";

const btn = document.querySelector("icon-button");
btn.icon = iconPencil(16);
btn.label = "Edit";
btn.addEventListener("click", () => console.log("edit clicked"));

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.

Auto-detected:
Forced macOS:
Forced other:
Navigation:
import "@f-ewald/components/kbd-hint.js";

<kbd-hint keys="Mod+K"></kbd-hint>
<kbd-hint keys="Mod+Shift+Enter" platform="mac"></kbd-hint>

live-timer

Per-second ticking count-up timer.

Sleeping:
Running:
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.

1

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).

1 2 🏠 💼 🎓

Configurable ring-opacity

3
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.`;

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

Required inside a form — submit to see each value repeated under colors, or reset to restore the defaults.

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>

pagination-nav

Minimal, controlled pager: previous/next around "Page N of M". It emits page-change and owns no data — you move the page in response, like data-table leaves the rows to you.

On page 1 of 5

import "@f-ewald/components/pagination-nav.js";

<pagination-nav current-page="1" total-pages="5"></pagination-nav>
<script type="module">
  document.querySelector("pagination-nav").addEventListener("page-change", (e) => {
    e.target.setAttribute("current-page", e.detail.page);
  });
</script>

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()}`;

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.

Full page ↗
Popover body content goes here.
Popover body content goes here.
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.

Freeway spread 1,000 ft

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>

reveal-button

Button that reveals hidden slotted content when clicked.

Surprise! This content was hidden. Surprise! This content was hidden.
import "@f-ewald/components/reveal-button.js";

<reveal-button label="Show the secret">
  Surprise! This content was hidden.
</reveal-button>

<!-- size="sm": one step below the default. -->
<reveal-button label="Show the secret" size="sm">
  Surprise! This content was hidden.
</reveal-button>

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>

slide-panel

Sliding panel shell with header chrome and a close button.

Panel body content goes here.
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.

Homepage
example.com
Status
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.

Sign in

Welcome back — enter your details below.

Sign in
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-banner

Full-width app-level bar for a persistent condition. Unlike toast-notification it does not auto-dismiss — the consumer renders it for as long as the condition holds. Supports a leading icon and trailing actions.

import "@f-ewald/components/status-banner.js";

<status-banner variant="warning">Reconnecting… — data may be stale</status-banner>
<status-banner variant="info">
  A new version is available.
  <button slot="actions">Reload</button>
</status-banner>

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).

Deployment started Release v1.4.0 is rolling out to production. Review approved Ada Lovelace approved the changes. Status changed Issue opened Reported by Alan Turing. Build failed CI halted on the release branch. Ticket filed Tracking issue #412 opened. System Compact status entry. Deploying Uploading build artifacts to the release bucket…
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.

Where it started A first stop, with the label on the left. The question The second entry mirrors the first. Setting out 2006–2008 A slotted label, styled from the light DOM. No label at all That side of the line is simply left 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.

These are balanced defaults — take the quiz to personalize them. Take the quiz This feature is in beta and may change. Deleting this workspace removes all its data.
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-button

Button (or link) with an optional icon, in three visual weights — plus an ai modifier that rings any variant with an animated multi-hue sweep (faster on hover and while busy) without altering its fill. Don't pair ai with pill under the gradient theme: the pill edge and gloss get absorbed into the ring.

+ New property Cancel Delete Saving Edit (as link) Abort Compare (2) Ask AI Summarize Thinking Toggle AI ring
Submit (type="submit")
import "@f-ewald/components/ui-button.js";

<ui-button variant="primary">
  <span slot="icon">...</span>
  New property
</ui-button>
<ui-button variant="danger">Delete</ui-button>
<ui-button variant="secondary" size="sm">Abort</ui-button>
<ui-button variant="secondary" href="/properties?edit=42">Edit</ui-button>
<ui-button variant="primary" pill>Compare (2)</ui-button>

<!-- `ai` layers an animated rainbow ring on any variant, flat or gradient.
     Don't pair it with `pill` under data-theme="gradient". -->
<ui-button variant="primary" ai>
  <span slot="icon">${iconSparkles(14)}</span>
  Ask AI
</ui-button>

ui-checkbox

Form-associated boolean checkbox, usable standalone or inside a native <form>.

Enable beta features

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>