Open Web Components glassmorphism

Zero-dependency web component library. Drop in one script tag.

Theme family
Colour mode
Page tint
<script src="https://cdn.jsdelivr.net/gh/reneoun/Open-Web-Components@v1.14.1/OWC/dist/components.js"></script>
Components
0Events fired
0Rows selected
0Btn clicks
0Searches

Keyboard shortcuts

Show this overlay?
Go to o-button1
Go to o-panel2
Go to o-toast3
Go to o-toggle4
Go to o-table5
Go to o-search6
Go to combined demo7
Fire random toastT
Close this overlayEsc
Expand all Collapse all Collapse categories Sections remember their state.

o-button

Glassmorphic button. Fires o-click instead of native click.

Click me Save Cancel
0 clicks
<o-button>Click me</o-button> // Event btn.addEventListener('o-click', () => console.log('clicked'))

o-button — simulated async loading

Disable and update text while awaiting an async operation — restore on completion.

Save to server Upload file Sync data
btn.addEventListener('o-click', async () => { btn.setAttribute('disabled', '') btn.textContent = 'Saving…' await doWork() btn.removeAttribute('disabled') btn.textContent = 'Save to server' toast('Saved!', 'success') })

o-button — confirm before firing

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 account Wipe 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.

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) Bottom Left Right Plain 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 me Drop into a cell And me Cells 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 all Collapse all Collapse top level only Expand 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.

Expand all Collapse all
Click a file, or focus the tree and use ↑ ↓ → ←
<o-tree selectable label="Project files"></o-tree> tree.data = [ { label: 'src', open: true, children: [ { label: 'components', children: [{ label: 'button.ts' }] }, { label: 'index.ts' }, ]}, { label: 'README.md' }, ] tree.expandAll(); tree.collapseAll() tree.addEventListener('o-tree-select', e => { console.log(e.detail.label, e.detail.path) // 'button.ts', [0,0,0] })

o-tabs

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.

Overview Installation Events Theming
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.
Via CDN
<script src="https://cdn.jsdelivr.net/gh/reneoun/Open-Web-Components@v1.14.1/OWC/dist/components.js"></script>
Via npm
npm install @owc/components
o-button → o-click
o-toggle → o-change { value, index, prev }
o-search → o-input, o-results, o-select
o-table → o-sort, o-row-select, o-cell-change
o-dialog → o-submit, o-cancel
o-note → o-change { value } / { title, body, tags }
o-dropdown → o-select { value, label }
o-tabs → o-change { value, prev }
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 names theme="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.

CSS custom properties Colours: --glass-bg, --glass-border, --glass-hover, --glass-text, --glass-text-muted, --glass-text-dim, --accent-warm, --glass-accent-text.
Depth: --glass-blur, --glass-backdrop, --glass-shadow, --glass-elevation, --glass-scrim.
Geometry: --glass-border-width, --glass-radius (plus -xs / -sm / -md / -lg / -xl / -2xl / -pill).
Page chrome: --glass-page-bg, --glass-page-text, --glass-chrome-bg, --glass-chrome-border.
Type & motion: --glass-font, --glass-press.

Set any of these on an ancestor prefixed with --owc- (e.g. --owc-glass-radius: 0) to patch a theme page-wide.
<o-tabs> <span slot="tab" data-value="one">Tab 1</span> <span slot="tab" data-value="two">Tab 2</span> <div data-tab="one">Content for tab one</div> <div data-tab="two">Content for tab two</div> </o-tabs> tabs.addEventListener('o-change', e => console.log(e.detail)) // { value, prev } tabs.value = 'two' // programmatic switch

o-note

Glass-styled note component. variant="textarea" (default) auto-resizes with optional floating label and char counter. variant="card" adds a title and tag chips.

Textarea variant

Card variant

<!-- textarea --> <o-note label="Note" placeholder="..." max-length="200"></o-note> <!-- card --> <o-note variant="card"></o-note> note.addEventListener('o-change', e => console.log(e.detail))

o-sidebar

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 rail Flip 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.

<o-table selectable></o-table> table.columns = [ { key: 'name', label: 'Name', width: 160, sortable: true }, { key: 'score', label: 'Score', width: 90, sortable: true, minWidth: 60 }, ] table.data = [{ name: 'Alice', score: 92 }, ...] // Events table.addEventListener('o-sort', e => console.log(e.detail)) // { col, dir } table.addEventListener('o-row-select', e => console.log(e.detail)) // { selected: row[] } // Attributes // storage="local|session" storage-key="my-key" — persists sort + column widths // selectable — adds checkbox column

o-table — dynamic data + undo/redo

Add, remove, or randomize rows at runtime. Every action is pushed to a history stack — undo and redo supported.

+ Add row - Remove last Randomize scores Reset
↩ Undo ↪ Redo 4 rows

o-table — editable cells

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.

<o-table editable></o-table> table.columns = [ { key: 'name', label: 'Name', width: 160, editable: 'always' }, { key: 'role', label: 'Role', width: 140, editable: 'click' }, { key: 'dept', label: 'Dept', width: 140, editable: 'click' }, ] // Click ✏️ → form row appears below with editable fields table.addEventListener('o-cell-change', e => console.log(e.detail)) table.addEventListener('o-row-change', e => console.log(e.detail))

Multi-search (AND filter)

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.

Panel variant
Table variant
Simulate load (2s) Reset
Block variant (custom size)
<!-- block (default) --> <o-skeleton width="200px" height="40px" radius="10px"></o-skeleton> <!-- while o-table loads --> <o-skeleton variant="table" rows="5"></o-skeleton> <!-- while o-panel loads --> <o-skeleton variant="panel"></o-skeleton>

o-progress

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.

Start Set 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 requests 5 requests 8 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.

<o-bar x="month" y="revenue" chart-title="Revenue by month"></o-bar> bar.data = [ { month: 'Jan', revenue: 128, new: 41, returning: 87 }, { month: 'Feb', revenue: 156, new: 52, returning: 104 }, ] <!-- grouped or stacked --> <o-bar x="month" series="new,returning" stacked></o-bar> <o-bar x="month" y="revenue" horizontal></o-bar>

o-line

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>

Glassmorphic search bar with built-in filtering and optional dropdown. Add no-dropdown to use it as a pure filter input.

<o-search placeholder="Search..." no-dropdown></o-search> search.data = [{ name: 'Apple' }, { name: 'Banana' }] search.searchKeys = ['name'] search.valueKey = 'name' // fills input on select search.renderItem = item => `<b>${item.name}</b>` // custom dropdown row // Custom filter (overrides searchKeys) search.filterFn = (query, item) => item.name.startsWith(query) // Events search.addEventListener('o-input', e => console.log(e.detail)) // { query } search.addEventListener('o-results', e => console.log(e.detail)) // { query, results } search.addEventListener('o-select', e => console.log(e.detail)) // { item, query }

o-search — rich dropdown with custom renderItem

Pass a renderItem function to build any dropdown layout — emoji, icons, multi-line, badges.

search.renderItem = item => ` <span style="font-size:18px">${item.icon}</span> <span><b>${item.name}</b><br> <small style="opacity:0.6">${item.desc}</small></span>`

Auto-complete with side effects

Select a country in the dropdown — the currency, flag, and continent fields auto-populate. One o-search driving multiple outputs.

Country
Flag
Currency
Continent
Dialing code
// One o-search drives multiple output fields search.items = countries.map(c => ({ label: c.name, value: c.name })) search.addEventListener('o-select', e => { const c = countries.find(x => x.name === e.detail.value) flag.textContent = c.flag currency.textContent = c.currency continent.textContent = c.continent dial.textContent = c.dial })

Form validation

A submit button that stays disabled until all required fields have content — built with o-search as text inputs and o-button as submit.

Submit Fill all fields to enable
<o-input id="fname" label="First name *" placeholder="Alice"></o-input> <o-input id="email" label="Email *" type="email" placeholder="name@example.com"></o-input> <o-button id="submit" disabled>Submit</o-button> const values = {} inputs.forEach(el => el.addEventListener('o-input', e => { values[el.id] = e.detail.value submit.toggleAttribute('disabled', !Object.values(values).every(Boolean)) }) ) // Show validation state on blur emailInput.addEventListener('o-change', e => { const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.detail.value) emailInput.setAttribute('error', valid ? '' : 'Enter a valid email') })

o-dropdown

Glassmorphic dropdown menu. Set options via the options property. Fires o-select with { value, label }. Keyboard-navigable with arrow keys and Escape.

Actions menu
Actions ▾
With icons
File ▾
Status
Set status ▾
const dd = document.querySelector('o-dropdown') dd.options = [ { label: 'Edit', value: 'edit', icon: '✏️' }, { label: 'Duplicate', value: 'duplicate', icon: '📋' }, { label: 'Delete', value: 'delete', icon: '🗑️' }, ] dd.addEventListener('o-select', e => { console.log(e.detail) // { value, label } }) // Programmatic control dd.open() dd.close() dd.toggle()

o-dialog

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.

Open dialog
Add team member
Add member Cancel
<o-dialog id="d"> <span slot="title">Add Item</span> <label>Name</label> <input name="name" type="text"> <div slot="actions"> <o-button type="submit">Save</o-button> </div> </o-dialog> document.getElementById('d').open() d.addEventListener('o-submit', e => console.log(e.detail)) // { name } d.addEventListener('o-cancel', () => console.log('cancelled'))

o-toast

Programmatic toast notifications. Call window.toast(message, type?, options?) after loading the CDN bundle.

Success Error Warning Info 8s toast
// type: 'success' | 'error' | 'warning' | 'info' (default: 'info') toast('File saved!', 'success') toast('Connection failed', 'error') toast('Long message', 'info', { duration: 8000 })

Custom toast builder

Compose a custom notification — type your message, pick a type, set a duration, and fire. Built entirely from OWC components.

Type
Duration
Fire toast
<o-input id="msg" label="Message" placeholder="Your message..."></o-input> <o-toggle id="type" options="Success,Error,Warning,Info" value="info"></o-toggle> <o-toggle id="dur" options="2s,4s,8s,∞" value="4s"></o-toggle> <o-button>Fire toast</o-button> const durMap = { '2s': 2000, '4s': 4000, '8s': 8000, '∞': 99999 } btn.addEventListener('o-click', () => toast(msg.value || 'Hello!', type.value.toLowerCase(), { duration: durMap[dur.value] }) )

Calculator

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 ÷ 7 8 9 × 4 5 6 1 2 3 + 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) }

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 +Info Fire 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.

Columns:
Export CSV
// Wire search to filter table data search.addEventListener('o-input', e => { const q = e.detail.query.toLowerCase() table.data = allData.filter(r => Object.values(r).some(v => String(v).toLowerCase().includes(q)) ) }) // Paginate tglPageSize.addEventListener('o-change', e => { pageSize = e.detail.value; render() }) btnPrev.addEventListener('o-click', () => { if (page > 0) { page--; render() } }) btnNext.addEventListener('o-click', () => { if ((page + 1) * pageSize < data.length) { page++; render() } }) // Export selected rows as CSV btnExport.addEventListener('o-click', () => { const rows = table.selected const csv = [cols.map(c => c.label), ...rows.map(r => cols.map(c => r[c.key]))].map(r => r.join(',')).join('\n') const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([csv])), download: 'export.csv' }) a.click() })

Kitchen sink

All components inside a single draggable + resizable o-panel — a practical multi-component composition.

Component playground
Search
Mode
Mode: Read
Actions
Save Publish Discard

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.