README (README.md)
Oat UI
Semantic, minimal, zero dependencies. ~10KB CSS and JS.
Oat is an ultra-lightweight HTML + CSS, semantic UI component library with zero dependencies. No framework, build, or dev complexity. Just include the tiny CSS and JS files and you are good to go building decent looking web applications with most commonly needed components and elements.
Semantic tags and attributes are styled contextually out of the box without classes, forcing best practices, and reducing markup class pollution. A few dynamic components are WebComponents and use minimal JavaScript.
I wrote this to use in my own projects after getting sick of the ridiculous bloat, dependencies, and rug-pulls in Javascript UI/component libraries.
See live demo and docs at oat.ink
IMPORTANT: The lib is currently sub v1 and is likely to have breaking changes until it hits v1.
-------------
<img width="739" height="735" alt="image" src="https://github.com/user-attachments/assets/b0a2f55c-659d-4aab-922c-b13d89eeab36" />
---
Package.Json (package.json)
{
"name": "@knadh/oat",
"version": "version-0.0.0",
"license": "MIT",
"author": "Kailash Nadh",
"homepage": "https://oat.ink",
"description": " Ultra-lightweight, zero dependency, semantic HTML/CSS/JS UI library",
"repository": {
"type": "git",
"url": "git+https://github.com/knadh/oat.git"
},
"keywords": ["ui", "components", "component-library"],
"files": [
"oat.min.css",
"oat.min.js",
"css",
"js"
],
"main": "oat.min.js",
"style": "oat.min.css",
"unpkg": "oat.min.js",
"jsdelivr": "oat.min.js"
}
---
Content/Customizing (docs/content/customizing.md)
+++
title = "Customizing"
description = "Customize and theme Oat by overriding CSS variables"
+++
Pretty much all properties of Oat are defined as CSS variables that can be overridden. See theme.css to see all variables. To override, redefine them in a CSS file in your project and include it after the lib's CSS files.
Picking and choosing
While it is quite okay to bundle all of Oat given how tiny it is, it is possible to include components selectively.
##### Must include
- 00-base.css
- 01-theme.css
- base.js
- your files after this
-------
Theming
The following color variables from theme.css control the theme (colour profile). Override them to create your own theme.
:root { / Page background /
--background: rgb(255 255 255);
/ Primary text color /
--foreground: rgb(9 9 11);
/ Card background /
--card: rgb(255 255 255);
/ Card text color /
--card-foreground: rgb(9 9 11);
/ Primary buttons and links /
--primary: rgb(87 71 71);
/ Text color on primary buttons /
--primary-foreground: rgb(250 250 250);
/ Secondary button background /
--secondary: rgb(244 244 245);
/ Text colour on secondary buttons /
--secondary-foreground: rgb(87 71 71);
/ Muted (lighter) background /
--muted: rgb(244 244 245);
/ Muted (lighter) text colour /
--muted-foreground: rgb(113 113 122);
/ Subtler than muted background /
--faint: rgb(250 250 250);
/ Subtler than muted text color /
--faint-foreground: rgb(161 161 170);
/ Accent background /
--accent: rgb(244 244 245);
/ Error/danger color /
--danger: rgb(211 47 47);
/ Text color on danger background /
--danger-foreground: rgb(250 250 250);
/ Success color /
--success: rgb(0 128 50);
/ Text colour on success background /
--success-foreground: rgb(250 250 250);
/ Warning color /
--warning: rgb(166 91 0);
/ Text colour on warning background /
--warning-foreground: rgb(9 9 11);
/ Border color (boxes) /
--border: rgb(212 212 216);
/ Input borders /
--input: rgb(212 212 216);
/ Focus ring color /
--ring: rgb(87 71 71);
}
After these, include CSS and JS files the respective components.
Example themes
Default Oat brown
--background: #fff;
--foreground: #09090b;
--card: #fff;
--card-foreground: #09090b;
--primary: #574747;
--primary-foreground: #fafafa;
--secondary: #f4f4f5;
--secondary-foreground: #574747;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--faint: #fafafa;
--faint-foreground: #a1a1aa;
--accent: #f4f4f5;
--danger: #d32f2f;
--danger-foreground: #fafafa;
--success: #008032;
--success-foreground: #fafafa;
--warning: #a65b00;
--warning-foreground: #09090b;
--border: #d4d4d8;
--input: #d4d4d8;
--ring: #574747;---------------
Dark mode
Dark mode is applied automatically via light-dark() and color-scheme: light dark, following the OS system preference. To customize the dark theme, redefine the theme variables scoped inside a [data-theme="dark"] selector in your own CSS. To forcibly set a theme, document.body.style.colorScheme = 'dark|light'.
---
Content/Demo (docs/content/demo.md)
+++
title = "Oat kitchensink dashboard demo"
template = "demo.html"
+++
---
Content/Extensions (docs/content/extensions.md)
+++
title = "Extensions"
description = "Community extensions for Oat UI"
+++
Third-party extensions that work with Oat.
- oat-chips: Chip/tag component with dismissible filters, colors, and toggle selection. ~1KB gzipped (Demo).
- oat-animate: Lightweight animation extension for Oat with declarative ot-animate triggers (on-load, hover, in-view) and reduced-motion support. ~1kb gzipped (Demo).
- oat-table: Semantic table enhancement for Oat UI. Sort, filter, and select rows without turning tables into a framework widget. (Demo).
- oat-upload: Dropzone, file previews, validation, removal, and progress for native <input type="file"> in Oat UI. (Demo).
-------
Open a PR to list an extension here.
---
Content/Other Libs (docs/content/other-libs.md)
+++
title = "Other useful libs"
description = "Useful zero-dependency libs"
+++
Zero-dependency, useful JS libs that can be used with Oat.
- tinyrouter.js: A tiny, zero-dependency lib for frontend routing and navigation on top of window.history. ~950 bytes.
- highlighted-input.js: Super tiny, zero dep Javascript lib that highlights specific keywords and tags in an <input> field. ~450 bytes
- floatype.js: A tiny, zero-dependency, floating autocomplete / autosuggestion widget for textareas. ~1200 bytes
- dragmove.js: A super tiny Javascript library to make DOM elements draggable and movable. ~500 bytes.
- indexed-cache.js: A tiny Javascript library for sideloading static assets on pages and caching them in the browser's IndexedDB for longer-term storage. ~2.1 KB
---
Content/Recipes (docs/content/recipes.md)
+++
title = "Recipes"
weight = 172
description = "Composable UI recipes using existing Oat components."
[extra]
webcomponent = true
+++
Examples for various composable widgets using Oat components.
--------
Split button
Use menu.buttons for joined controls and ot-dropdown for secondary actions.
{% demo() %}
<ot-dropdown>
<menu class="buttons">
<li><button class="outline">Save</button></li>
<li>
<button class="outline" popovertarget="save-actions" aria-label="More save actions">
More
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</li>
</menu>
<menu popover id="save-actions">
<button role="menuitem" class="ghost">Save draft</button>
<button role="menuitem" class="ghost">Save and publish</button>
<button role="menuitem" class="ghost">Duplicate</button>
</menu>
</ot-dropdown>{% end %}
Radio cards
Wrap each option in a <label> so the whole card is selectable, and group them in a <fieldset> with a <legend>.
{% demo() %}
<fieldset class="w-100">
<legend>Billing</legend>
<p class="text-light">Select a billing cycle</p> <div class="row">
<label class="col-4 card vstack">
<span class="w-100 hstack justify-between">
<strong>Monthly</strong>
<input type="radio" name="billing">
</span>
<span class="text-light">$12 / mo</span>
</label>
<label class="col-4 card vstack">
<span class="w-100 hstack justify-between">
<strong>Yearly</strong>
<input type="radio" name="billing">
</span>
<span class="text-light">$96 / yr · save 33%</span>
</label>
<label class="col-4 card vstack">
<span class="w-100 hstack justify-between">
<strong>Lifetime</strong>
<input type="radio" name="billing" checked>
</span>
<span class="text-light">$299 once</span>
</label>
</div>
</fieldset>
{% end %}
Form card
Group related form fields inside a card with standard field containers and actions.
{% demo() %}
<article class="card">
<header>
<h3>Profile</h3>
<p class="text-light">Update account information</p>
</header> <div class="mt-4">
<label data-field>
Name
<input type="text" value="Your name" />
</label>
<label data-field>
Email
<input type="email" value="[email protected]" />
</label>
<label data-field>
<input type="checkbox" role="switch" checked> Email notifications
</label>
</div>
<footer class="hstack justify-end mt-4">
<button class="outline">Cancel</button>
<button>Save</button>
</footer>
</article>
{% end %}
Empty state
Use a card, text, and primary actions for list/result empty states.
{% demo() %}
<article class="card align-center">
<h3>Nothing here yet</h3>
<p class="text-light">Why don't you create something?</p>
<footer class="hstack justify-center mt-4">
<button>New something</button>
</footer>
</article>{% end %}
Stats cards
Compose dashboard metrics with grid, card, badge, and progress/meter.
{% demo() %}
<div class="container">
<div class="row">
<article class="card col-4">
<header class="hstack justify-between items-center">
<h4>Revenue</h4>
<span class="badge" data-variant="success">+12%</span>
</header>
<h2>$42,200</h2>
<p class="text-light">vs last month</p>
<progress value="72" max="100"></progress>
</article> <article class="card col-4">
<header class="hstack justify-between items-center">
<h4>Completion</h4>
<span class="badge" data-variant="warning">-2%</span>
</header>
<h2>4.6%</h2>
<p class="text-light">checkout completion</p>
<meter value="0.46" min="0" max="1" low="0.3" high="0.7" optimum="1"></meter>
</article>
<article class="card col-4">
<header class="hstack justify-between items-center">
<h4>Tickets</h4>
<span class="badge">14</span>
</header>
<h2>14</h2>
<p class="text-light">support queue</p>
<progress value="35" max="100"></progress>
</article>
</div>
</div>
{% end %}
---
Content/Usage (docs/content/usage.md)
+++
title = "Installation and usage"
+++
IMPORTANT: The lib is currently sub v1 and is likely to have breaking changes until it hits v1.
CDN
Include the CSS and JS files directly in your HTML:
<link rel="stylesheet" href="https://unpkg.com/@knadh/oat/oat.min.css">
<script src="https://unpkg.com/@knadh/oat/oat.min.js" defer></script>----------
npm
npm install @knadh/oatThen import in your project:
import '@knadh/oat/oat.min.css';
import '@knadh/oat/oat.min.js';Or import individual files from @knadh/oat/css and @knadh/oat/js.
----------
Download
Download the CSS and JS files:
wget https://raw.githubusercontent.com/knadh/oat/refs/heads/gh-pages/oat.min.css
wget https://raw.githubusercontent.com/knadh/oat/refs/heads/gh-pages/oat.min.jsThen include them in your project:
<link rel="stylesheet" href="./oat.min.css">
<script src="./oat.min.js" defer></script>Basic usage
Oat styles semantic HTML elements by default. No classes needed for basic styling:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My App</title>
<link rel="stylesheet" href="oat.min.css">
<script src="oat.min.js" defer></script>
</head>
<body>
<h1>Hello World</h1>
<p>This paragraph is styled automatically.</p>
<button>Click me</button>
</body>
</html>--------
Local dev setup
Requirements
- zola static site generator installed to preview the docs/demo site and to try out changes.
- esbuild installed for bundling+minifying JS and CSS.
Running
- Clone the oat repo
-
cd docs and run zola serve to access the docs/demo site at http://localhost:1111- After changing any CSS or JS files, run
make dist. The demo site auto-updates with the changes.---
Content/Components/ Index (docs/content/components/_index.md)
+++
title = "Components"
template = "components/section.html"
sort_by = "weight"
page_template = "components/page.html"
+++
---
Content/Components/Accordion (docs/content/components/accordion.md)
+++
title = "Accordion"
weight = 20
description = "Collapsible sections using native <details> and <summary> elements. No JS required."
+++
Use native <details> and <summary> for collapsible content.
{% demo() %}
<details>
<summary>What is Oat</summary>
<p class="p-4">Oat is a minimal, semantic-first UI component library with zero dependencies.</p>
</details><details>
<summary>How do I use it</summary>
<p class="p-4">Include the CSS and JS files, then write semantic HTML. Most elements are styled by default.</p>
</details>
<details>
<summary>Is it accessible</summary>
<p class="p-4">Yes! It uses semantic HTML and ARIA attributes. Keyboard navigation works out of the box.</p>
</details>
<details name="same">
<summary>This is grouped with the next one</summary>
<p class="p-4">Using the <code>name</code> attribute groups items like radio.</p>
</details>
<details name="same">
<summary>This is grouped with the previous one</summary>
<p class="p-4">Using the <code>name</code> attribute groups items like radio.</p>
</details>
{% end %}
---
Content/Components/Alert (docs/content/components/alert.md)
+++
title = "Alert"
weight = 30
description = "Alert messages using role=\"alert\"."
+++
Use role="alert" for alert styling. Set data-variant for success, warning, or error.
{% demo() %}
<div role="alert" data-variant="success">
<strong>Success!</strong> Your changes have been saved.
</div><div role="alert" data-variant="warning">
<strong>Warning!</strong> Please review before continuing.
</div>
<div role="alert">
<strong>Info</strong> This is a default alert message.
</div>
<div role="alert" data-variant="error">
<strong>Error!</strong> Something went wrong.
</div>
{% end %}
---
Content/Components/Avatar (docs/content/components/avatar.md)
+++
title = "Avatar"
weight = 31
description = "Avatars are used to represent users or entities visually. They can be images, icons, or text initials."
+++
Use <figure data-variant="avatar"> with an <img> tag to create an avatar. Can also use text initials with <abbr> or icons instead of image.
{% demo() %}
<figure data-variant="avatar" class="small" aria-label="Jane Doe">
<img src="/avatar.svg" alt="" />
</figure><figure data-variant="avatar" aria-label="Oat">
<abbr title="Jane Doe">OT</abbr>
</figure>
<figure data-variant="avatar" aria-label="Jane Doe">
<img src="/avatar.svg" alt="" />
</figure>
<figure data-variant="avatar" class="large" aria-label="Jane Doe">
<img src="/avatar.svg" alt="" />
</figure>
{% end %}
Avatar group
Wrap avatars in <figure data-variant="avatar" role="group"> for grouped avatars. To control the size of all avatars in the group, add .small or .large to the group container.
{% demo() %}
<figure data-variant="avatar" role="group" class="small" aria-label="Team members">
<figure data-variant="avatar" aria-label="Jane Doe">
<img src="/avatar.svg" alt="" />
</figure>
<figure data-variant="avatar" aria-label="John Smith">
<img src="/avatar.svg" alt="" />
</figure>
<figure data-variant="avatar" aria-label="Alex Lee">
<img src="/avatar.svg" alt="" />
</figure>
</figure><figure data-variant="avatar" role="group" aria-label="Team members">
<figure data-variant="avatar" aria-label="Jane Doe">
<img src="/avatar.svg" alt="" />
</figure>
<figure data-variant="avatar" aria-label="John Smith">
<img src="/avatar.svg" alt="" />
</figure>
<figure data-variant="avatar" aria-label="Alex Lee">
<img src="/avatar.svg" alt="" />
</figure>
</figure>
<figure data-variant="avatar" role="group" class="large" aria-label="Team members">
<figure data-variant="avatar" aria-label="Jane Doe">
<img src="/avatar.svg" alt="" />
</figure>
<figure data-variant="avatar" aria-label="John Smith">
<img src="/avatar.svg" alt="" />
</figure>
<figure data-variant="avatar" aria-label="Alex Lee">
<img src="/avatar.svg" alt="" />
</figure>
</figure>
{% end %}
---
Content/Components/Badge (docs/content/components/badge.md)
+++
title = "Badge"
weight = 40
description = "Inline badge / tag / pill labels."
+++
Use .badge with data-variant for color variants. .outline is a style modifier.
{% demo() %}
<span class="badge">Default</span>
<span class="badge" data-variant="secondary">Secondary</span>
<span class="badge outline">Outline</span>
<span class="badge" data-variant="success">Success</span>
<span class="badge" data-variant="warning">Warning</span>
<span class="badge" data-variant="danger">Danger</span>{% end %}
---
Content/Components/Breadcrumb (docs/content/components/breadcrumb.md)
+++
title = "Breadcrumb"
weight = 45
description = "Simple navigation hierarchy using nav and ordered lists"
+++
Use a semantic breadcrumb <nav> with an ordered list and aria-current="page" for the active item.
{% demo() %}
<nav aria-label="Breadcrumb">
<ol class="unstyled hstack" style="font-size: var(--text-7)">
<li><a href="#breadcrumbs" class="unstyled">Home</a></li>
<li aria-hidden="true">/</li>
<li><a href="#breadcrumbs" class="unstyled">Projects</a></li>
<li aria-hidden="true">/</li>
<li><a href="#breadcrumbs" class="unstyled">Oat Docs</a></li>
<li aria-hidden="true">/</li>
<li><a href="#breadcrumbs" class="unstyled" aria-current="page"><strong>Breadcrumb</strong></a></li>
</ol>
</nav>{% end %}
---
Content/Components/Button (docs/content/components/button.md)
+++
title = "Button"
weight = 50
description = "Button variants and sizes"
+++
The <button> element is styled by default. Use data-variant="secondary|danger" for semantic variants and classes for visual styles.
{% demo() %}
<button>Primary</button>
<button data-variant="secondary">Secondary</button>
<button data-variant="danger">Danger</button>
<button class="outline">Outline</button>
<button data-variant="danger" class="outline">Danger</button>
<button class="ghost">Ghost</button>
<button class="outline" disabled>Disabled</button>
<button data-variant="danger" disabled>Disabled</button>
<button disabled>Disabled</button>{% end %}
Sizes
Use .small or .large for size variants.
{% demo() %}
<button class="small">Small</button>
<button>Default</button>
<button class="large">Large</button>
<a href="#button" class="button">Hyperlink</a>{% end %}
Button group
Wrap buttons in <menu class="buttons"> for connected buttons.
{% demo() %}
<menu class="buttons">
<li><button class="outline">Left</button></li>
<li><button class="outline">Center</button></li>
<li><button class="outline">Right</button></li>
</menu>{% end %}
---
Content/Components/Card (docs/content/components/card.md)
+++
title = "Card"
weight = 60
description = "Card containers using the semantic <article> tag."
+++
Use class="card" for a visual box-like card look.
{% demo() %}
<article class="card">
<header>
<h3>Card Title</h3>
<p>Card description goes here.</p>
</header>
<p>This is the card content. It can contain any HTML.</p>
<footer class="hstack">
<button class="outline">Cancel</button>
<button>Save</button>
</footer>
</article>{% end %}
---
Content/Components/Dialog (docs/content/components/dialog.md)
+++
title = "Dialog"
weight = 70
description = "Modal dialogs using native <dialog> with command/commandfor."
+++
Fully semantic, zero-Javascript, dynamic dialog with <dialog>. Use commandfor and command="show-modal" attributes on an element to open a target dialog. Focus trapping, z placement, keyboard shortcuts all work out of the box.
{% demo() %}
<button commandfor="demo-dialog" command="show-modal">Open dialog</button>
<dialog id="demo-dialog" closedby="any">
<form method="dialog">
<header>
<h3>Title</h3>
<p>This is a dialog description.</p>
</header>
<div>
<p>Dialog content goes here. You can put any HTML inside.</p>
<p>Click outside or press Escape to close.</p>
</div>
<footer>
<button type="button" commandfor="demo-dialog" command="close" class="outline">Cancel</button>
<button value="confirm">Confirm</button>
</footer>
</form>
</dialog>{% end %}
With form fields
Forms inside dialogs work naturally. Use command="close" on cancel buttons to close.
{% demo() %}
<button commandfor="demo-dialog-form" command="show-modal">Open form dialog</button>
<dialog id="demo-dialog-form">
<form method="dialog">
<header>
<h3>Edit form</h3>
</header>
<div class="vstack">
<label>Name <input name="name" required></label>
<label>Email <input name="email" type="email"></label>
</div>
<footer>
<button type="button" commandfor="demo-dialog-form" command="close" class="outline">Cancel</button>
<button value="save">Save</button>
</footer>
</form>
</dialog>{% end %}
Handling return value
Listen to the native close event to get the button value:
const dialog = document.querySelector("#demo-dialog");
dialog.addEventListener('close', (e) => {
console.log(dialog.returnValue); // "confirm"
});or use onclose inline:
<dialog id="my-dialog" onclose="console.log(this.returnValue)">---
Content/Components/Dropdown (docs/content/components/dropdown.md)
+++
title = "Dropdown"
weight = 80
description = "Dropdown menus and popover using the native popover API. Supports keyboard navigation."
[extra]
webcomponent = true
+++
Wrap in <ot-dropdown>. Use popovertarget on the trigger and popover on the target. If a dropdown <menu>, items use role="menuitem".
{% demo() %}
<ot-dropdown>
<button popovertarget="demo-menu" class="outline">
Options
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
<menu popover id="demo-menu">
<button role="menuitem" class="ghost">Profile</button>
<button role="menuitem" class="ghost" popovertarget="demo-menu" popovertargetaction="hide">Click to close</button>
<button role="menuitem" class="ghost">Help</button>
<a href="#" role="menuitem" class="unstyled">Link</a>
<hr>
<button role="menuitem" class="ghost">Logout</button>
<button role="menuitem" data-variant="danger" class="ghost">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
Delete
</button>
</menu>
</ot-dropdown>{% end %}
Popover
<ot-dropdown> can also be used to show popover dropdown elements.
{% demo() %}
<ot-dropdown>
<button popovertarget="demo-confirm" class="outline">
Confirm
</button>
<article class="card" popover id="demo-confirm">
<header>
<h4>Are you sure?</h4>
<p>This action cannot be undone.</p>
</header>
<br />
<footer>
<button class="outline small" popovertarget="demo-confirm">Cancel</button>
<button data-variant="danger" class="small" popovertarget="demo-confirm">Delete</button>
</footer>
</article>
</ot-dropdown>{% end %}
---
Content/Components/Form (docs/content/components/form.md)
+++
title = "Form elements"
weight = 90
description = "Inputs, selects, textareas, checkboxes, radios, and fieldsets"
+++
Form elements are styled automatically. Wrap inputs in <label> for proper association.
{% demo() %}
/ Detailed source-code truncated for AI context efficiency. /{% end %}
Input group
Use .group on a <fieldset> to combine inputs with buttons or labels.
{% demo() %}
<fieldset class="group">
<legend>https://</legend>
<input type="url" placeholder="subdomain">
<select aria-label="Select a subdomain">
<option value="" disabled selected>Select</option>
<option>.example.com</option>
<option>.example.net</option>
</select>
<button>Go</button>
</fieldset><fieldset class="group">
<input type="text" placeholder="Search" />
<button>Go</button>
</fieldset>
{% end %}
Validation error
Use aria-invalid="true" on field containers to reveal and style error messages.
{% demo() %}
<fieldset class="vstack">
<div data-field>
<label for="email-error-input">Email</label>
<input type="email" aria-describedby="email-error-message" id="email-error-input" placeholder="Type invalid email here" autocomplete="off" />
<div id="email-error-message" class="error" role="status">Please enter a valid email address</div>
</div>
<label data-field aria-invalid="true">
Enter secret value
<input type="password" aria-invalid="true" id="new-password" aria-describedby="new-password-error" placeholder="Enter new secret" value="abcdefg"/>
<div id="new-password-error" class="error" role="status">The value is incorrect</div>
</label>
</fieldset>{% end %}
---
Content/Components/Grid (docs/content/components/grid.md)
+++
title = "Grid"
weight = 170
description = "12-column responsive grid using container queries and CSS grid."
+++
A 12-column grid system using CSS grid. Use .container, .row, and .col classes. Column widths use .col-{n} where n is 1-12.
{% demo() %}
<div class="container demo-grid">
<div class="row">
<div class="col-4">col-4</div>
<div class="col-4">col-4</div>
<div class="col-4">col-4</div>
</div>
<div class="row">
<div class="col-6">col-6</div>
<div class="col-6">col-6</div>
</div>
<div class="row">
<div class="col-3">col-3</div>
<div class="col-6">col-6</div>
<div class="col-3">col-3</div>
</div>
<div class="row">
<div class="col-4 offset-2">col-4 offset-2</div>
<div class="col-4">col-4</div>
</div>
<div class="row">
<div class="col-8">col-8</div>
<div class="col-3 offset-1">col-3 offset-1</div>
</div>
<div class="row">
<div class="col-3">col-3</div>
<div class="col-4 col-end">col-4 col-end</div>
</div>
</div>{% end %}
---
Content/Components/Meter (docs/content/components/meter.md)
+++
title = "Meter"
weight = 100
description = "Measurements using the semantic <meter> element."
+++
Use <meter> for values within a known range. Browser shows colors based on low/high/optimum attributes.
{% demo() %}
<meter value="0.8" min="0" max="1" low="0.3" high="0.7" optimum="1"></meter>
<meter value="0.5" min="0" max="1" low="0.3" high="0.7" optimum="1"></meter>
<meter value="0.2" min="0" max="1" low="0.3" high="0.7" optimum="1"></meter>{% end %}
---
Content/Components/Pagination (docs/content/components/pagination.md)
+++
title = "Pagination"
weight = 105
description = "Pagination nav bars."
+++
Pagination does not use any special markup or classes and re-uses the existing buttons <menu>.
{% demo() %}
<nav aria-label="Pagination">
<menu class="buttons">
<li><a href="#pagination" class="button outline small">← Previous</a></li>
<li><a href="#pagination" class="button outline small">1</a></li>
<li><a href="#pagination" class="button outline small">2</a></li>
<li><a href="#pagination" class="button small" aria-current="page">3</a></li>
<li><a href="#pagination" class="button outline small">4</a></li>
<li><a href="#pagination" class="button outline small">5</a></li>
<li><a href="#pagination" class="button outline small">Next →</a></li>
</menu>
</nav>{% end %}
---
Content/Components/Progress (docs/content/components/progress.md)
+++
title = "Progress"
weight = 110
description = "Progress bars using the native <progress> element."
+++
Use the native <progress> element.
{% demo() %}
<progress value="60" max="100"></progress>
<progress value="30" max="100"></progress>
<progress value="90" max="100"></progress>{% end %}
---
Content/Components/Sidebar (docs/content/components/sidebar.md)
+++
title = "Sidebar"
weight = 120
description = "Responsive admin dashboard layout with sticky sidebar, optional topnav, and collapsible sections."
+++
- Use data-sidebar-layout on a container (typically <body>) with <aside data-sidebar> for the sidebar and <main> for content. The sidebar stays sticky while the main content scrolls.
- On mobile, the sidebar becomes a slide-out overlay toggled by a [data-sidebar-toggle] button. To show the toggle and make it collapse the sidebar at all widths, set data-sidebar-layout="always".
- Set the --sidebar-width variable to adjust its width globally.
<div class="sidebar-example">
{% demo() %}
<div data-sidebar-layout>
<aside data-sidebar>
<nav>
<ul>
<li><a href="#sidebar" aria-current="page">Home</a></li>
<li><a href="#sidebar">Users</a></li>
<li>
<details open>
<summary>Settings</summary>
<ul>
<li><a href="#sidebar">General</a></li>
<li><a href="#sidebar">Security</a></li>
<li><a href="#sidebar">Billing</a></li>
</ul>
</details>
</li>
</ul>
</nav>
<footer>
<button class="outline small" style="width: 100%;">Logout</button>
</footer>
</aside>
<main>
<div style="padding: var(--space-3)">Main content area. Scrolls with the page body.</div>
</main>
</div>{% end %}
</div>
Always-collapsible
Set data-sidebar-layout="always" to keep the toggle visible and make it collapse the sidebar on all screen sizes.
<body data-sidebar-layout="always">
...
</body>With top sticky nav
Add data-topnav to a nav element for a full-width top navigation bar. The sidebar will adjust to sit below it. Inspect the HTML source of this website for a live example.
<body data-sidebar-layout>
<nav data-topnav>
<button data-sidebar-toggle aria-label="Toggle menu" class="outline">☰</button>
<span>App Name</span>
</nav> <aside data-sidebar>
<header>Logo</header>
<nav>...navigation...</nav>
<footer>Actions</footer>
</aside>
<main>
Main page content.
</main>
</body>
#### Structure
| Attribute | Element | |
| ------------------------------ | ---------- | ------------------------------------------------------------------------------ |
| data-sidebar-layout | Container | Grid layout wrapper (sidebar + main), typically <body> |
| data-sidebar-layout="always" | Container | Always-collapsible sidebar (toggle visible and functional on all screen sizes) |
| data-topnav | <nav> | Full-width top nav (optional, spans full width) |
| data-sidebar | <aside> | Sticky sidebar element |
| data-sidebar-toggle | <button> | Toggles sidebar (mobile) and collapse (always mode) |
| data-sidebar-open | Layout | Applied to layout when sidebar is open |
---
Content/Components/Skeleton (docs/content/components/skeleton.md)
+++
title = "Skeleton"
weight = 117
description = "Loading placeholders with shimmer animation."
+++
Use .skeleton with role="status" for loading placeholders. Add .line for text or .box for images.
{% demo() %}
<div role="status" class="skeleton line"></div>
<div role="status" class="skeleton box"></div>{% end %}
Skeleton card
Put skeleton loader inside <article> to get a card layout.
{% demo() %}
<article style="display: flex; gap: var(--space-3); padding: var(--space-6);">
<div role="status" class="skeleton box"></div>
<div style="flex: 1; display: flex; flex-direction: column; gap: var(--space-1);">
<div role="status" class="skeleton line"></div>
<div role="status" class="skeleton line" style="width: 60%"></div>
</div>
</article>{% end %}
---
Content/Components/Spinner (docs/content/components/spinner.md)
+++
title = "Spinner"
weight = 115
description = "Loading indicators with role=\"status\"."
+++
Use aria-busy="true" on any element to show a loading indicator. Size with data-spinner="small|large".
{% demo() %}
<div class="hstack" style="gap: var(--space-8)">
<div aria-busy="true" data-spinner="small"></div>
<div aria-busy="true"></div>
<div aria-busy="true" data-spinner="large"></div>
<button aria-busy="true" data-spinner="small" disabled>Loading</button>
</div>{% end %}
Overlay
Adding
data-spinner="overlay" dims contents of the container and overlays the spinner on top.{% demo() %}
<article class="card" aria-busy="true" data-spinner="large overlay">
<header>
<h3>Card Title</h3>
<p>Card description goes here.</p>
</header>
<p>This is the card content. It can contain any HTML.</p>
<footer class="flex gap-2 mt-4">
<button class="outline">Cancel</button>
<button>Save</button>
</footer>
</article>{% end %}
---
Content/Components/Switch (docs/content/components/switch.md)
+++
title = "Switch"
weight = 130
description = "Toggle switches using checkbox with role=\"switch\". Native HTML, no JS required."
+++
Add role="switch" to a checkbox for toggle switch styling.
{% demo() %}
<label>
<input type="checkbox" role="switch"> Notifications
</label>
<label>
<input type="checkbox" role="switch" checked> Confabulation
</label>{% end %}
Disabled
{% demo() %}
<label>
<input type="checkbox" role="switch" disabled> Disabled off
</label>
<label>
<input type="checkbox" role="switch" checked disabled> Disabled on
</label>{% end %}
---
Content/Components/Table (docs/content/components/table.md)
+++
title = "Table"
weight = 140
description = "Data tables with thead, tbody. Styled automatically."
+++
Tables are styled by default. Use <thead> and <tbody> tags. Wrap in a class="table" container to get a horizontal scrollbar on small screens.
{% demo() %}
<div class="table">
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>root@localhost</td>
<td>[email protected]</td>
<td>Admin</td>
<td><span class="badge" data-variant="success">Active</span></td>
</tr>
<tr>
<td>Byte Bandit</td>
<td>[email protected]</td>
<td>Editor</td>
<td><span class="badge">Active</span></td>
</tr>
<tr>
<td>Null Pointer</td>
<td>[email protected]</td>
<td>Viewer</td>
<td><span class="badge" data-variant="secondary">Pending</span></td>
</tr>
<tr>
<td>Bit Stream</td>
<td>[email protected]</td>
<td>Editor</td>
<td><span class="badge">Active</span></td>
</tr>
<tr>
<td>code@localhost</td>
<td>[email protected]</td>
<td>Admin</td>
<td><span class="badge" data-variant="success">Active</span></td>
</tr>
</tbody>
<tfoot>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
</tr>
</tfoot>
</table>
</div>{% end %}
---
Content/Components/Tabs (docs/content/components/tabs.md)
+++
title = "Tabs"
weight = 150
description = "Tabbed interface using a custom WebComponent and semantic behaviour."
[extra]
webcomponent = true
+++
Wrap tab buttons and panels in <ot-tabs>. Use role="tablist", role="tab", and role="tabpanel".
Optionally, add data-anchor="<key>" to <ot-tabs> and give each role="tab" an id to change the page's URL hash fragment to remember/deep-link to the tab on a new page load.
{% demo() %}
<ot-tabs data-anchor="tab-settings">
<div role="tablist">
<button role="tab">Account</button>
<button role="tab" id="password">Password</button>
<button role="tab" id="notifications">Notifications</button>
</div>
<div role="tabpanel">
<h3>Account Settings</h3>
<p>Manage your account information here. This tab doesn't have an anchor id.</p>
</div>
<div role="tabpanel">
<h3>Password Settings</h3>
<p>Change your password here. This tab change's the URL's anchor.</p>
</div>
<div role="tabpanel">
<h3>Notification Settings</h3>
<p>Configure your notification preferences. This tab change's the URL's anchor.</p>
</div>
</ot-tabs>{% end %}
---
Content/Components/Taginput (docs/content/components/taginput.md)
+++
title = "TagInput"
weight = 85
description = "Type words and press Enter or comma to turn them into a tags collection. Supports autocomplete."
[extra]
webcomponent = true
+++
Use <ot-taginput>. Type a word and press <kbd>Enter</kbd> or <kbd>,</kbd> (comma) to add.
{% demo() %}
<ot-taginput value="apple, mango">
<input placeholder="Add tags ..." maxlength="15" />
</ot-taginput><ot-taginput value="apple, mango" disabled>
<input placeholder="Disabled taginput ..." maxlength="15" />
</ot-taginput>
{% end %}
Autocomplete
Give the <input> a list and a <datalist>, then populate the datalist from the input's native oninput and onfocus handler. Selecting a suggestion creates a tag.
A suggestion item can be a plain string or an object. Attach the object to its <option> via option.data.
{% demo() %}
<ot-taginput id="taginput-demo">
<input list="fruit-list" placeholder="Type a fruit name" oninput="tagInputAutoComplete(this)">
<datalist id="fruit-list"></datalist>
</ot-taginput>{% end %}
<script>
class Fruit {
constructor(id, name) { this.id = id; this.name = name; }
toString() { return this.name; } // Display text
}function tagInputAutoComplete(el) {
// A mix of plain strings and objects.
const fruits = [
'Apple',
'Apricot',
new Fruit(1, 'Banana'),
new Fruit(2, 'Cherry'),
'Mango',
'Melon',
];
el.list.replaceChildren(...fruits
.filter(f => String(f).toLowerCase().startsWith(el.value.toLowerCase()))
.map(f => {
const o = new Option(f);
o.data = f;
return o;
}));
}
</script>
Programmatic read and write
Mutate the value property of the component. A standard input event is dispatched (and bubbles) whenever a tag is added or removed.
<ot-taginput id="tags"><input /></ot-taginput><script>
const el = document.getElementById('tags');
el.value = ['apple', 'mango']; // replace all
el.value = [...el.value, 'kiwi']; // append
el.value = []; // clear
console.log(el.value); // read
el.addEventListener('input', e => {
console.log(e.detail); // ['apple', 'mango'] <-- current tags
});
</script>
Options
| Property | Description |
| ------------- | ----------------------------------------------------------------------- |
| <input> | Child input field where the user types |
| value | Comma-separated list of initial tags. |
| disabled | Disables the control like a native <input>. |
| .value | Array of tags (strings or objects). Setting this does not emit input. |
| .disabled | Boolean prop for the disabled attribute. |
| option.data | Optional object attached to a <datalist> <option>. |
| input event | Dispatched (bubbles) on add/remove. detail is the current tag array. |
---
Content/Components/Toast (docs/content/components/toast.md)
+++
title = "Toast"
weight = 160
description = "Notification toasts with placement and stacking."
+++
Show toast notifications with ot.toast(message, title?, options?).
{% demo() %}
<button onclick="ot.toast('Action completed successfully', 'All good', { variant: 'success' })">Success</button>
<button onclick="ot.toast('Something went wrong', 'Oops', { variant: 'danger', placement: 'top-left' })" data-variant="danger">Danger</button>
<button onclick="ot.toast('Please review this warning', 'Warning', { variant: 'warning', placement: 'bottom-right' })" class="outline">Warning</button>
<button onclick="ot.toast('New notification', 'For your attention', { placement: 'top-center' })">Info</button>{% end %}
Placement
ot.toast('Top left', '', { placement: 'top-left' })
ot.toast('Top center', '',{ placement: 'top-center' })
ot.toast('Top right', '',{ placement: 'top-right' }) // default
ot.toast('Bottom left', '', { placement: 'bottom-left' })
ot.toast('Bottom center', '', { placement: 'bottom-center' })
ot.toast('Bottom right', '',{ placement: 'bottom-right' })Options
| Option | Default | Description |
| ----------- | ------------- | ------------------------------------ |
| variant | 'info' | 'success', 'danger', 'warning' |
| placement | 'top-right' | Position on screen |
| duration | 4000 | Auto-dismiss in ms (0 = persistent) |
Custom markup
Use ot.toast.el(element, options?) to show toasts with custom HTML content.
{% demo() %}
<template id="undo-toast">
<output class="toast" data-variant="success">
<h6 class="toast-title">Changes saved</h6>
<p>Your document has been updated.</p>
<button data-variant="secondary" class="small" onclick="this.closest('.toast').remove()">Okay</button>
</output>
</template><button onclick="ot.toast.el(document.querySelector('#undo-toast'), { duration: 8000 })">
Toast with action
</button>
{% end %}
From a template:
ot.toast.el(document.querySelector('#my-template'))
ot.toast.el(document.querySelector('#my-template'), { duration: 8000, placement: 'bottom-center' })Dynamic element:
const el = document.createElement('output');
el.className = 'toast';
el.setAttribute('data-variant', 'warning');
el.innerHTML = '<h6 class="toast-title">Warning</h6><p>Custom content here</p>';
ot.toast.el(el);The element is cloned before display, so templates can be reused.
Clearing toasts
ot.toast.clear() // Clear all
ot.toast.clear('top-right') // Clear specific placement---
Content/Components/Tooltip (docs/content/components/tooltip.md)
+++
title = "Tooltip"
weight = 155
description = "Smooth tooltips using the native title attribute."
+++
Use the standard title attribute on any element to render a tooltip with smooth transition. Replaced elements like <img>, <iframe> etc. need to be wrapped in a parent with the title attribute. Add data-tooltip-placement to position the tooltip. Default is top.
{% demo() %}
<button title="Save your changes">Save</button>
<button title="Delete this item" data-variant="danger">Delete</button>
<a href="#" title="View your profile">Profile</a>
<span title="Images need a parent with title"><img src="https://oat.ink/logo.svg" height="32" /></span><button title="On top">Top</button>
<button title="Below" data-tooltip-placement="bottom">Bottom</button>
<button title="On the left" data-tooltip-placement="left">Left</button>
<button title="On the right" data-tooltip-placement="right">Right</button>
{% end %}
---
Content/Components/Typography (docs/content/components/typography.md)
+++
title = "Typography"
weight = 10
description = "Headings, paragraphs, lists, code blocks, and other text elements. All styled by default."
+++
Base text elements are styled automatically. No classes needed.
{% demo() %}
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
<h4>Heading 4</h4>
<h5>Heading 5</h5>
<h6>Heading 6</h6><p>This is a paragraph with <strong>bold text</strong>, <em>italic text</em>, and <a href="#">a link</a>.</p>
<p>Here's some <code>inline code</code> and a code block:</p>
<pre><code>function hello() {
console.log('Hello, World!');
}</code></pre>
<blockquote>
This is a blockquote. It's styled automatically.
</blockquote>
<hr>
<ul>
<li>Unordered list item 1</li>
<li>Unordered list item 2</li>
<li>Unordered list item 3</li>
</ul>
<ol>
<li>Ordered list item 1</li>
<li>Ordered list item 2</li>
<li>Ordered list item 3</li>
</ol>
{% end %}
---
Content/Components/Upload (docs/content/components/upload.md)
+++
title = "Upload"
weight = 95
description = "Tiny click and drag/drop file uploader."
[extra]
webcomponent = true
+++
Wrap a native <input type="file" /> in <ot-upload>. The change event is fired on selection, drop, and removal of files.
{% demo() %}
<ot-upload>
<div data-field class="vstack">
<input type="file" name="attachments" multiple hidden />
<strong>Attachments</strong> <button type="button" class="ghost" aria-label="Choose files">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M12 16V4m0 0 4 4m-4-4-4 4" /><path d="M20 16v4H4v-4" /></svg>
</button>
<div data-files>
<small data-hint>Drop files here or click to choose</small>
</div>
</div>
</ot-upload>
{% end %}
---
Content/Components/Utilities (docs/content/components/utilities.md)
+++
title = "Utils and helpers"
weight = 1000
description = "Utility and helper classes."
+++
See utilities.css for commonly used utility and helper classes.
---
.Github/Workflows/Publish Website.Yml (.github/workflows/publish-website.yml)
on:
workflow_dispatch:
push:
branches:
- master
name: Build and deploy GH Pages
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v6
with:
node-version: '24'
- name: install esbuild
run: npm install esbuild
- name: build assets
run: make dist
- name: build_and_deploy
uses: shalzz/[email protected]
env:
# Build docs site from ./docs
BUILD_DIR: docs
# Target branch
PAGES_BRANCH: gh-pages
# Or if publishing to the same repo, use the automatic token
TOKEN: ${{ secrets.GITHUB_TOKEN }}
---