Add confirm and the first click arms the button instead of firing: the label swaps to a prompt and a sweep drains across the bottom for the length of the window. A second click within it fires o-click; letting it lapse reverts. Arming another confirm button cancels the first.
Delete accountWipe cache (6s)No confirm — fires at once
Nothing fired yet.
<o-button confirm="Really delete?">Delete account</o-button>
<o-button confirm confirm-timeout="6000">Wipe cache</o-button>
// o-click fires only on the confirming click
btn.addEventListener('o-click', e => remove(e.detail.confirmed))
btn.addEventListener('o-confirm-pending', () => {})
btn.addEventListener('o-confirm-cancel', e => e.detail.reason)
o-toggle
Segmented control toggle. Pass options as a comma-separated string or array. Fires o-change with { value, index }.
Selected: Day — change the toggle above to see this update live.
<o-toggle options="Day,Week,Month" value="day"></o-toggle>
// Or set via JS
toggle.options = [{ label: 'Day', value: 'day' }, { label: 'Week', value: 'week' }]
toggle.value = 'week'
// Event
toggle.addEventListener('o-change', e => console.log(e.detail)) // { value, index, prev }
o-toggle — as a tab switcher
Use o-toggle to drive tab content — swap any panel based on the selected value.
Overview
Open Web Components is a glassmorphic web component library. Drop in one script tag and get beautiful, zero-dependency UI components that work in any framework.
API reference
Components expose attributes (move, resize, snap) and fire custom DOM events (o-click, o-change, o-input, o-results, o-select, o-sort, o-row-select).
Examples
See the full interactive demos on this page. Each section has copy-ready code snippets. Press ? for keyboard shortcuts.
o-toggle — color swatch picker
Drive any visual state from o-toggle — here a live color preview controlled by the selected option.
Hex: #3b82f6
o-input
Glassmorphic text input with optional static label and validation states. Fires o-input on every keystroke and o-change on blur.
Default
Error state
Success state
Disabled
<o-input label="Name" placeholder="Alice"></o-input>
<o-input label="Email" error="Invalid email"></o-input>
<o-input label="User" success></o-input>
<o-input label="Key" disabled></o-input>
input.addEventListener('o-input', e => console.log(e.detail)) // { value }
input.addEventListener('o-change', e => console.log(e.detail)) // { value }
o-tooltip
Glassmorphic tooltip that wraps any element. Appears on hover or focus. Supports four positions via the position attribute.
Hover me (top)BottomLeftRightPlain span
<o-tooltip text="Hello!" position="top">
<o-button>Hover me</o-button>
</o-tooltip>
<!-- position: top (default) | bottom | left | right -->
o-panel
Glassmorphic content panel. Supports dragging, grid snapping, and resizing.
Draggable + snap 20px
Grab ⠿ to move. Snaps to 20px grid.
Resizable panel
Drag the right / bottom / corner handles.
Drag + snap 40px + resize
All features combined.
Static panel
No attributes.
<o-panel>Static content</o-panel>
<o-panel move>Draggable</o-panel>
<o-panel move snap="20">Snaps to 20px grid while dragging</o-panel>
<o-panel resize>Resizable (right / bottom / corner handles)</o-panel>
<o-panel move snap="20" resize>All combined</o-panel>
o-panel — attribute playground
Toggle attributes live and see the generated markup update in real time.
Playground panel
Toggle checkboxes above to change attributes.
<o-panel>Content</o-panel>
o-dropzone — grid drop-zone
Drag a panel by its handle over the area below. The grid appears only while dragging, the cell under the panel highlights, and on release the panel snaps to fill it. Panels keep their free-drag behaviour — nothing reflows, and dragging back out releases the panel to float again.
No panel dropped yet.
Drag meDrop into a cellAnd meCells can overlap
<o-dropzone cols="3" rows="2" gap="10">
<o-panel move snap="10">Drag me</o-panel>
</o-dropzone>
// Fires once the panel lands in a cell
dz.addEventListener('o-drop', e => {
const { panel, col, row } = e.detail
console.log(`landed at column ${col}, row ${row}`)
})
o-collapse
Nestable disclosure panels. o-collapse-group adds bulk control — collapseAll(), expandAll(), and collapse(labels) / expand(labels) for a subset. This whole page is built from them. Add accordion to keep one panel open at a time, and storage-key to remember state across visits.
Expand allCollapse allCollapse top level onlyExpand only "Shipping"
A nested collapse keeps its own state — collapse the parent, reopen it, and the child is exactly as you left it.
2 × Mechanical keyboard — €178.00
Ships in 2–3 business days.
Visa ending 4242.
<o-collapse-group id="group" accordion storage-key="my-panels">
<o-collapse open label="Order details">
<o-collapse label="Shipping">…nested to any depth…</o-collapse>
</o-collapse>
</o-collapse-group>
group.expandAll() // every panel, at every depth
group.collapseAll({ topLevelOnly: true }) // leave nested state intact
group.expand(['Shipping']) // target a subset by label
group.openLabels // read or assign the open set
group.addEventListener('o-collapse-toggle', e => {
console.log(e.detail.label, e.detail.open)
})
o-tree
A collapsible tree. Feed it a nested .data array or write <o-tree-node> markup — both render through one shadow root so tree / treeitem / group stays a valid accessibility tree. Full arrow-key navigation with a roving tabindex, so the whole tree is one tab stop.
Glassmorphic tab bar. Add slot="tab" children with data-value for the tab headers, and data-tab on panel elements for content. Fires o-change with { value, prev }. Arrow-key navigable.
OverviewInstallationEventsTheming
Open Web Components
A glassmorphic web component library — zero dependencies, one script tag. All components are built with native Custom Elements and Shadow DOM, making them compatible with any framework or plain HTML.
Two independent axes
A theme is a family (the visual language) crossed with a mode (the colour
scheme). All six combinations work:
Families: glass, pixel, office ×
Modes: light, dark
Geometry — radii, blur, font, border width — belongs to the family and never changes
with mode, so toggling light/dark cannot move a pixel.
Whole page — one or both attributes: <body data-owc-theme="office" data-owc-mode="dark">
Single component — always wins over the page: <o-button theme="pixel" mode="light">OK</o-button> mode on its own keeps the page's family and swaps only its palette.
With no mode set, the mode follows the visitor's OS
(prefers-color-scheme). An explicit mode always beats the OS.
Legacy namestheme="light" and theme="dark" shipped in v1.0 and still work —
they are pinned aliases for glass + light and glass + dark.
Contrast
Every family × mode is checked against WCAG AA by the test suite: body text at
4.5:1 and decorative text, accents and scrollbars at 3:1, measured by compositing each
translucent layer over the one beneath it.
Glass-styled note component. variant="textarea" (default) auto-resizes with optional floating label and char counter. variant="card" adds a title and tag chips.
The same component driving this page's navigation, shown here unfixed so it sits in the flow. Collapses to an icon rail rather than disappearing, so navigation stays reachable. Use side="right" to flip it.
Toggle railFlip side
v1.14.1
Content area
The sidebar reserves its own width when unfixed, so this panel reflows as it collapses.
<o-sidebar label="Library" width="180" rail-width="48">
<o-search slot="search" no-dropdown></o-search>
<nav><a href="#a">Basics</a><a href="#b">Layout</a></nav>
<small slot="footer">v1.14.1</small>
</o-sidebar>
sidebar.toggle() // or .collapse() / .expand()
sidebar.side = 'right' // flip which edge it hugs
sidebar.addEventListener('o-sidebar-toggle', e =>
console.log(e.detail.collapsed))
<!-- add `fixed` to pin it to the viewport edge -->
o-table
Sortable, resizable, glassmorphic data table. Supports column persistence and optional row selection.
editable="always" renders inline inputs. editable="click" opens a form row below the original with label:input pairs. Press ✏️ to edit, ✓ to confirm, ✗ to cancel.
Two o-search bars filtering the same dataset simultaneously — results must match both queries. Paged with page-size, which resets to page 1 whenever the filters change.
Clear
// Two o-search bars — results must satisfy both filters simultaneously
s1.addEventListener('o-input', e => { q1 = e.detail.query; render() })
s2.addEventListener('o-input', e => { q2 = e.detail.query; render() })
function render() {
const a = q1.toLowerCase(), b = q2.toLowerCase()
// Assigning .data resets the table to page 1 automatically
table.data = allData.filter(r =>
(!a || r.name.toLowerCase().includes(a) || r.dept.toLowerCase().includes(a)) &&
(!b || r.company.toLowerCase().includes(b))
)
}
// <o-table page-size="8"> renders its own pager and emits o-page
table.addEventListener('o-page', e => console.log(e.detail.page))
o-skeleton
Pulsing glassmorphic placeholder for loading states. Three variants: block (default), table (matches o-table), and panel (matches o-panel). Swap in wherever content is loading.
Fixed top-of-page loading bar. Use the static API or set the value attribute (0–100). OProgress.done() or value="100" animates to full then fades out automatically.
StartSet 50%Set 75%Done
Value attribute (drag to set)
0%
<o-progress></o-progress>
// Static API (works without a JS reference)
OProgress.start() // auto-increments slowly
OProgress.set(60) // jump to 60%
OProgress.done() // shoot to 100% then fade out
// Attribute API
document.querySelector('o-progress').setAttribute('value', '75')
asyncPlus
asyncPlus(...promises) wraps multiple promises and automatically drives the progress bar — starts on the first call, increments as each settles, completes when all are done. Fires a progress-complete event on document.
Simulate parallel requests
3 requests5 requests8 requests (1 fails)
// Auto-drives OProgress for any set of promises
const results = await asyncPlus(
fetch('/api/users'),
fetch('/api/posts'),
fetch('/api/comments')
)
// results: PromiseSettledResult[] — never rejects
// Fires when all settle
document.addEventListener('progress-complete', e => {
console.log(e.detail.results)
})
o-bar
Columns for comparing magnitude. A single series wears one colour for every bar — colouring bars by their own value would spend the identity channel re-encoding what bar length already shows. Add series for grouped or stacked bars, horizontal to flip it. Hover or focus a column for the readout; every value is also in the table view.
Trend over time. The crosshair snaps to the nearest point and one tooltip reports every series at that x — you aim at a date, never at a 2px stroke. Focus the plot and use ← → for the same readout by keyboard. Add area for a single-series wash.
<o-line x="day" series="visitors,signups"
chart-title="Traffic this week"></o-line>
line.data = [
{ day: 'Mon', visitors: 820, signups: 41 },
{ day: 'Tue', visitors: 932, signups: 55 },
]
<!-- single series with an area wash -->
<o-line x="day" y="visitors" area></o-line>
o-pie
Part-to-whole, at a glance — and deliberately constrained, because that is all a pie does well. It caps at six slices and folds the tail into Other rather than inventing a seventh colour. For comparing close values, reach for o-bar instead. Add donut for a total in the middle.
<o-pie label="os" value="users" chart-title="Sessions by platform"></o-pie>
pie.data = [
{ os: 'macOS', users: 4820 },
{ os: 'Windows', users: 3910 },
{ os: 'Linux', users: 1340 },
]
<!-- donut, and a tighter cap -->
<o-pie label="os" value="users" donut max-slices="4"></o-pie>
o-search
Glassmorphic search bar with built-in filtering and optional dropdown. Add no-dropdown to use it as a pure filter input.
Glassmorphism form dialog with blurred backdrop, slot-based content, and programmatic open/close. Fires o-submit with named input values and o-cancel on backdrop click or Escape.
A working calculator assembled only from OWC components — o-panel shell, o-input display, o-button keypad, o-toast for errors. Click the keys or type; Esc clears, Backspace deletes.
Calculator
C←÷789×456−123+0.=
// Every key is an o-button; the display is an o-input
keys.addEventListener('click', e => {
const btn = e.target.closest('o-button')
if (btn) press(btn.dataset.key)
})
// Divide-by-zero surfaces through o-toast rather than showing NaN
if (op === '/' && b === 0) {
toast({ message: 'Cannot divide by zero', type: 'error' })
return clear()
}
o-panel — kanban board
Three o-panel columns as a kanban board. Move items between columns using the arrow buttons.
To do (3)
In progress (2)
Done (1)
<!-- Three o-panel columns as a kanban board -->
<div style="display:flex;gap:12px">
<o-panel id="todo">...</o-panel>
<o-panel id="doing">...</o-panel>
<o-panel id="done">...</o-panel>
</div>
// Move item between columns
function move(item, fromCol, toCol) {
fromCol.items = fromCol.items.filter(i => i !== item)
toCol.items = [...toCol.items, item]
renderColumn(fromCol)
renderColumn(toCol)
}
↑ Top
o-panel — as a live widget
An o-panel used as a real-time widget host — draggable clock showing that panels can hold any live content.
00:00:00
Notification queue
+Success+Error+InfoFire queue
Queue: 0 toasts
// Clock: update every second
setInterval(() => {
clock.textContent = new Date().toLocaleTimeString()
}, 1000)
// Notification queue: fire with staggered delays
queue.forEach((item, i) => {
setTimeout(() => toast(item.msg, item.type), i * 800)
})
Search + Table + Pagination
Live example combining o-search, o-table (selectable), o-toggle for pages + page size, and o-button for prev/next.
All components inside a single draggable + resizable o-panel — a practical multi-component composition.
Component playground
Search
Mode
Mode: Read
Actions
SavePublishDiscard
Chat window
A chat UI assembled entirely from existing components — o-sidebar for the session list, o-scroll for the transcript, o-input and o-button for the composer, o-skeleton as the typing indicator. Pick a session, or send a message and watch it reply.
Deploy pipeline
Send
<div class="chat-frame">
<o-sidebar label="Sessions" width="176" rail-width="46">
<div id="sessions"></div>
</o-sidebar>
<div class="chat-main">
<o-scroll id="log"><div id="inner"></div></o-scroll>
<div class="chat-composer">
<o-input id="msg" placeholder="Write a message..."></o-input>
<o-button id="send">Send</o-button>
</div>
</div>
</div>
// Enter sends; the transcript scrolls to the newest message.
msg.addEventListener('keydown', e => { if (e.key === 'Enter') send() })
send.addEventListener('o-click', send)
Live event log
All component events from this page, logged in real time.
Events (0)Clear
No events yet — interact with the components above.