A UI to rule them all.
One UI gives every app the same cards, buttons, colours and dark mode, so moving between them feels like using one product.
Not related to Samsung. This One UI is a separate project from Samsung's One UI Android skin; the two only share a name. It is not affiliated with, endorsed by or based on Samsung, and it does not copy or follow Samsung's design.
The demo shows every element filled with made-up sample content. Use the sun/moon button in the top right to try light and dark, the five colours and the glass look. There is also a plain HTML version.
One UI is developed by AuxXxilium, with theme and design by Fulcrum.
If you use One UI, all of it or any part, you must name both AuxXxilium and Fulcrum somewhere your users can see it, such as an About page, a footer or the sidebar. Removing or hiding the credit is not allowed. See NOTICE.
You don't have to write the credit yourself:
- The ready-made layout (
AppShell) already shows it at the bottom of the sidebar. <Credits />draws the credit block, e.g. on your About page (see Credits).- In plain HTML, copy the
oui-creditsline from Plain HTML.
- What it looks like
- Two ways to use it
- Light, dark, colour and glass
- App icons
- The elements
- Plain HTML (no build step)
- What is in this folder
- Changing the demo and the pictures
| Light | Dark |
|---|---|
![]() |
![]() |
| Glass, light | Glass, dark, amber |
![]() |
![]() |
It also works on a phone: the sidebar folds away behind the ☰ button, and wide tables scroll sideways inside their card.
| You are building… | Use | You get |
|---|---|---|
| An app with Preact and Tailwind | the preset in src/ |
Ready-made components: <Card>, <Button>, <Modal> … |
| A plain web page with no build step | the plain version in plain/ |
One CSS file and one small script. You write normal HTML with oui- classes. |
Both look the same and remember the same settings, so a plain page opened next to an app changes colour with it.
1. Add One UI to your stylesheet, after Tailwind:
@import 'tailwindcss' source(none);
@source '.';
@import '../node_modules/one-ui/src/one-ui.css';One UI tells Tailwind where its own components are, so you don't need to add anything else.
2. Build your app with the layout and a few pieces:
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { AppShell, PageHeader, Card, Button, useTheme } from 'one-ui';
const NAV = [
{ id: 'home', label: 'Overview', icon: 'main' },
{ id: 'tasks', label: 'Tasks', icon: 'list', section: 'Work' },
{ id: 'settings', label: 'Settings', icon: 'settings', section: 'Account' },
];
export default function App() {
const theme = useTheme(); // light/dark, colour and glass, remembered
const [page, setPage] = useState('home');
return (
<AppShell title="My App" subtitle="Hello there" logo="logo.png"
theme={theme} nav={NAV} active={page} onSelect={setPage}>
<PageHeader icon="main" title="Overview" subtitle="Everything at a glance">
<Button icon="refresh" variant="outline">Refresh</Button>
</PageHeader>
<Card title="Welcome" icon="info">Your content goes here.</Card>
</AppShell>
);
}That is a complete app: title bar, sidebar, appearance menu, credit line and a page.
One UI needs
preact(10.19 or newer) andtailwindcss(4.1 or newer). Each file importsh, so it works with both ways of building Preact JSX (pragma: 'h'and the automatic runtime).
<link rel="stylesheet" href="one-ui-plain.css">
<script src="one-ui-plain.js"></script>Then write ordinary HTML with the classes listed under Plain HTML.
The sun/moon button opens the appearance menu. It has three settings, and each one is remembered in the browser.
| Setting | Choices | What it does |
|---|---|---|
| Mode | Light · Dark | The whole interface turns dark or light. On a first visit it follows the computer's own setting. |
| Surface | Solid · Glass | Glass makes every box slightly see-through, with a soft blur behind it, over a stronger coloured background. |
| Color | Red · Blue · Amber · Graphite · Pastel Green | The highlight colour: buttons, the current page, links, progress bars. |
Status colours never change with the highlight: green always means good and red always means a problem.
Another colour? Add an entry to ACCENT_PRESETS in src/theme.js, with its shades from 50 to 950 like the five that are there. It shows up in the menu by itself.
In code: useTheme() does all of this for you. Pass it options if your app already stores the settings under other names, or uses a different starting colour:
// Keep the names an app already stores its settings under, and start on amber:
const theme = useTheme({ accentKey: 'myapp_accent', darkKey: 'myapp_dark', defaultAccent: 'amber' });
// Start new visitors on glass:
const theme = useTheme({ defaultGlass: true });Only one glass box: <Card glass> makes just that card see-through, even when the rest of the app is solid.
Menus: a box that floats over the page - a drop-down, a right-click menu, a popover - takes panel panel-menu instead of panel. Under glass it is clearer than the boxes around it, so it does not turn into a flat white sheet over a light card. Mark its hover rows and dividers with a tint (bg-black/5 dark:bg-white/10) rather than a grey, which would lay a solid bar across the glass. The appearance menu is built this way, and so is the card menu on the demo's Menus page: a <Card overflow="visible"> whose "more" button carries aria-expanded, so the card is lifted above the ones after it while the menu is open.
| Card menu, glass, light | Card menu, glass, dark, amber |
|---|---|
![]() |
![]() |
A box that marks itself with an inset shadow - an accent bar down one side, say - draws it on an ::after covering the box (relative after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_…]). Under glass the tint and blur are a layer over everything the box itself draws, and a shadow on the box would come out washed. The sidebar's current page does it this way.
Every Arc app's icon starts from the same flat icon: a slate rounded square with a white symbol and one red detail. One script turns it into one of three finished styles. The symbol itself is never redrawn: it is lifted out of the flat icon exactly, so every style keeps the artwork pixel for pixel.
Each picture shows the same four icons on white, on a dark background and on a colourful wallpaper.
| Style | Looks like |
|---|---|
| Flat (the source) | |
| Frosted | |
| Frosted 3D | |
| Glyph 3D |
| Style | What it is | Use it when |
|---|---|---|
frosted |
The square as frosted glass: see-through, milky, with a fine grain, a glow along the edges and a bright rim. The wallpaper shows through. | The icon sits on a desktop or a dark menu. |
frosted-3d |
The same glass, with the symbol standing off it and the square showing its thickness, with a soft shadow under it. | As above, with more depth. |
glyph-3d |
No square: the symbol alone and larger, with a white face in a slate outline over a dark side and a small shadow. | The icon also appears on white: menus, window title bars, Package Center. Glass turns pale on white and the white symbol fades with it, so this is the style the Arc apps use. |
Making one:
python icons/app-icon.py --style glyph-3d my-app-flat.png -o my-app.png
python icons/app-icon.py --style frosted my-app-flat.png -o my-app.png- The script needs Python 3 with Pillow and NumPy (
pip install pillow numpy). --styleisfrosted,frosted-3dorglyph-3d(the default).--fillsets how much of the icon the symbol fills inglyph-3d, as a share. The default is0.72.- The source must be a flat icon in the template: 1280 × 1280 px, a 1024 px square with rounded corners, the slate gradient, a white symbol and red details. Keep the flat file. A finished icon can't be used as the source again.
Each element below has a picture, what it is for, and the shortest code that uses it. Icons are given by name, e.g. icon="refresh". The Icons section lists them all.
The whole frame of an app: the title bar at the top, the page list on the left and your page on the right, on the soft coloured background. Start here.
<AppShell title="My App" subtitle="Plan your projects" logo="logo.png"
theme={useTheme()} nav={NAV} active={page} onSelect={setPage}>
…your page…
</AppShell>| Option | Meaning |
|---|---|
title, subtitle, logo |
The app's name in the title bar. logo is a picture address, or anything you want to show. |
theme |
What useTheme() gives you. Adds the appearance menu. |
nav, active, onSelect |
The pages in the sidebar, which one is open, and what to do when one is clicked. |
actions |
Extra buttons in the title bar, next to the appearance menu. |
credits |
The One UI credit at the bottom of the sidebar. On by default. Only turn it off if you show <Credits /> somewhere else. |
The page list. AppShell already includes it; use it on its own only if you build your own frame.
Each page in the list is { id, label, icon }. Add section: 'Hardware' to put it under a heading; headings fold away when clicked, and that is remembered. Add href: 'https://…' to make it a link that opens in a new tab (it gets a small ↗).
The current page is a white pill with a coloured bar on its left. The ☰ button shrinks the sidebar to icons only.
The top of every page: a coloured icon tile, the page title, a line under it, and the page's own buttons on the right.
<PageHeader icon="list" title="Tasks" subtitle="12 open, 3 due today"
warning="Two tasks are overdue.">
<Button icon="plus">New task</Button>
</PageHeader>warning is optional and shows in amber with a ⚠ icon. Buttons placed inside stay grouped on the right and wrap on small screens.
The white box everything sits in. It can have a title bar with an icon, a short description and buttons on the right.
<Card title="Tasks" subtitle="3 of 5 done" icon="check"
actions={<Button size="xs" variant="secondary" icon="plus">Add</Button>}>
…content…
</Card>| Option | Meaning |
|---|---|
title, subtitle, icon |
The title bar. Leave them out for a plain box. |
actions |
Buttons on the right of the title bar. |
iconTone |
Colours the icon to show a state, e.g. "text-red-500" for failed. iconSpin makes it turn. |
glass |
Makes only this card see-through. |
overflow="visible" |
Lets a drop-down menu stick out of the card instead of being cut off. Under glass the card is lifted above the cards after it while the menu is open, so they do not cover it - when the menu is a <details>, or its button carries aria-expanded="true". |
A card with two or more views, switched by clicking the title bar. The view you are not looking at still shows its short description, so "2 away" is visible without switching.
<TabbedCard value={view} onChange={setView} tabs={[
{ id: 'details', title: 'Project details', subtitle: 'Project Apollo', icon: 'info' },
{ id: 'team', title: 'Team', subtitle: '2 away', icon: 'user' },
]}>
{view === 'details' ? <Details /> : <Team />}
</TabbedCard>One fact in a small grey box: a small label above a value. Use them for order numbers, names and dates.
<InfoTile label="Order" value="ORD-20418" />
<InfoTile label="Customer" value="Mira Holt" mono={false} />Values are shown in a typewriter font, which suits codes and numbers. Use mono={false} for normal words. TILE is the same grey box as a class, for your own content: <div className={${TILE} p-3}>…</div>.
Seven looks and four sizes, with or without an icon.
<Button>Save</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="danger" icon="trash">Delete</Button>
<Button variant="ghost" icon="gear" iconOnly>Settings</Button>variant |
Use it for |
|---|---|
primary (default) |
The main action on a page or dialog. One per area. |
secondary |
Everything else: Cancel, Close, Back. |
outline |
An action in the highlight colour that is less loud than primary, e.g. Refresh. |
ghost |
Quiet actions in a row, e.g. the small buttons on a table line. |
success · warning · danger |
Actions with a clear outcome: Start, Force, Delete. |
| Option | Meaning |
|---|---|
size |
xs, sm (default), md, lg. sm is right almost everywhere. |
icon |
An icon in front of the text. Add spin to make it turn while something is working. |
iconOnly |
Shows only the icon. The text becomes the tooltip and what screen readers say. |
disabled |
Greys the button out. |
A small coloured word that says how something is.
<StatusBadge status="success" label="Done" />
<StatusBadge status="error" label="Overdue" title="Was due on 12 October" />status is success (green), warning (yellow), error (red), info (highlight colour) or left out (grey). title adds hover text for details that don't fit in one word.
Choose one view out of a few. The chosen one is lifted out as a white pill, and a number can follow each choice.
<Segmented value={filter} onChange={setFilter} items={[
{ id: 'all', label: 'All', count: 67 },
{ id: 'open', label: 'Open', count: 6 },
{ id: 'done', label: 'Done', disabled: true },
]} />The views of one page, across the top. Use these when one sidebar entry has several screens, e.g. Summary, Activity, Files. The open one is white with a coloured line under it.
<PageTabs value={tab} onChange={setTab} items={[
{ id: 'summary', label: 'Summary', icon: 'main' },
{ id: 'activity', label: 'Activity', icon: 'activity' },
{ id: 'files', label: 'Files', icon: 'file', count: 12 },
]} />Segmented, PageTabs or TabbedCard? PageTabs switch the whole page. TabbedCard switches one card. Segmented filters or sorts what is inside a card.
An on/off switch for settings that take effect immediately, with no Save button.
<ToggleSwitch checked={enabled} onChange={setEnabled} label="Email updates" />label is read out by screen readers when there is no visible text beside it.
Text fields, drop-downs and checkboxes use three ready-made class strings, so every form looks the same. Fields are exactly as tall as a button, so they line up on one row.
import { inputClass, labelClass, hintClass } from 'one-ui';
<label className={labelClass}>Display name</label>
<input className={inputClass} value={name} onInput={…} />
<p className={hintClass}>This is how others see you.</p>You can also write class="field" instead of inputClass; it looks the same. Checkboxes, radio buttons and sliders take the highlight colour automatically.
A value people can click to copy, e.g. a share link or a key. It shows "Copied" for a moment afterwards, and it also works on plain http:// addresses where many copy buttons fail.
<CopyField label="Share link:" value="https://example.com/share/4f9a2c" />copyText('…') is the same copying on its own, for your own buttons.
A dialog over the page. Escape, the ✕ or a click outside closes it.
// To show something:
<Modal isOpen={open} onClose={close} title="Project Apollo">…</Modal>
// To ask before doing something (gives Cancel + your button):
<Modal isOpen={open} onClose={close} title="Delete this file?" size="sm"
message="It will be removed for good."
onConfirm={remove} confirmLabel="Delete" confirmVariant="danger" />
// To say it worked (small, closes itself after 2 seconds):
<Modal isOpen={open} onClose={close} type="success" title="Done" message="Saved." />| Option | Meaning |
|---|---|
size |
sm, md (default), lg, xl. |
footer |
Your own buttons for the bottom bar, or false for no bottom bar. By default it shows a Close button. |
onConfirm, confirmLabel, confirmVariant |
Turns the bottom bar into Cancel + a confirm button. |
A message in a coloured box. Optional close button.
<Notice tone="warning">Three tasks are due tomorrow.</Notice>
<Notice tone="error" title="Not saved" onClose={hide}>The file could not be saved.</Notice>tone is info (highlight colour), success, warning or error. Each tone has its own icon, or you can choose one with icon.
<Loading message="Loading files…" /> // a spinner; size 6, 8, 10 or 16
<Empty icon="file">No files in this folder yet.</Empty>
<ProgressBar value={0.42} /> // 0.42 means 42 %
<ProgressBar value={0.91} tone="bg-red-500" /> // a status colour instead of the highlightUse these instead of writing your own, so waiting and "nothing here" look the same on every page.
A right-click menu. It opens at the pointer, moves itself back inside the window near an edge, and opens upward when there is no room below. Up and Down move between the items, Escape closes it, and so does a click elsewhere, scrolling or resizing. It is clearer glass than the cards, like every menu.
| Glass, light, red | Solid, dark, graphite |
|---|---|
![]() |
![]() |
const [menu, setMenu] = useState(null);
<Card onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation(); // so the page's own menu does not replace it
setMenu({ x: e.clientX, y: e.clientY, title: 'Project Granite', items: [
{ label: 'Rename', icon: 'edit', onClick: rename },
{ label: 'Share link', icon: 'link', onClick: share },
'separator',
{ label: 'Delete', icon: 'trash', danger: true, onClick: remove },
] });
}}>…</Card>
<ContextMenu menu={menu} onClose={() => setMenu(null)} />An item is { label, onClick, icon, danger, disabled, title }, or 'separator' for a line between groups. Picking one closes the menu before its action runs, so a dialog it opens is not drawn under the menu.
Turn the browser's menu off where an app has its own: useNoBrowserMenu() once, in the app. Right-click then opens the app's menu where it has one, and does nothing anywhere else. Text fields keep the browser's menu, for paste and spelling. The demo does this: its Menus page has a menu on each card and one for the page, and right-clicking anywhere else does nothing.
A small live reading in the bottom-right corner, from Firevisor's host widget. Closed it is one button. Open it shows up to three dials (shares of a whole: CPU, memory, storage), tiles of paired rates (network down and up, disk read and write), a bar per volume and one figure in the title bar, such as the hottest sensor. Whether it is open is remembered.
| Solid, light | Solid, dark |
|---|---|
![]() |
![]() |
| Glass, light | Glass, dark, amber |
![]() |
![]() |
It only draws: your app fetches the numbers and passes them in. Fetch only while it is open, and only while the page is on screen - useVisiblePoll does both, and waits for each answer before asking again. A monitor should not cost the machine it watches.
const [open, setOpen] = useState(false);
const [s, setS] = useState(null);
useVisiblePoll(async () => { if (open) setS(await api.stats()); }, 5000, open, open);
<StatsWidget
title="Host"
open={open}
onOpenChange={setOpen}
loading={!s}
badge={s && { text: `${s.temp}°C`, title: 'CPU package' }}
rings={s ? [{ label: 'CPU', pct: s.cpu }, { label: 'Memory', pct: s.mem }, { label: 'Storage', pct: s.disk }] : []}
rates={s ? [
{ label: 'Network', items: [{ key: '↓', value: s.rx, tone: 'bg-blue-500' }, { key: '↑', value: s.tx, tone: 'bg-violet-500' }] },
{ label: 'Disk', items: [{ key: 'Read', value: s.read, tone: 'bg-green-500' }, { key: 'Write', value: s.write, tone: 'bg-amber-500' }] },
] : []}
bars={s ? s.volumes.map((v) => ({ label: v.name, pct: v.pct, detail: `${formatBytes(v.free)} free` })) : []}
/>| Option | Meaning |
|---|---|
rings |
Up to three dials: { label, pct, value }. value is the text inside, pct with a % by default. |
rates |
Tiles of two rates each: { label, items: [{ key, value, tone, text }] }. value is bytes per second, shown with formatRate; null shows a dash, for "no second reading yet". Both bars in a tile share one scale: the largest rate it has shown since the widget opened. |
bars |
One line each: { label, pct, detail, title }. |
badge |
One figure in the title bar: { text, title }. |
loading, error |
"Reading…", or the error text in red. |
open, onOpenChange |
Open or closed, from your app. Leave open out and the widget keeps it itself, remembered under storageKey. |
glass |
Makes only this widget see-through, like <Card glass>. |
className |
Where it sits; fixed bottom-4 right-4 z-40 by default. |
Dials and volume bars are green, then amber from 70 % and red from 90 %: status colours, never the highlight, so a dial never reads as healthy at 95 %. Its tracks and the title-bar figure are tints, so under glass they do not lay solid bars across it. formatRate and formatBytes are exported too.
Put data-table on any table for the One UI look: a quiet grey header, roomy rows and a tint under the row the mouse is over. On a phone, a table that is too wide scrolls sideways inside its card.
<table className="data-table w-full text-sm">
<thead><tr><th>Name</th><th>Status</th></tr></thead>
<tbody><tr><td>Brand guide.pdf</td><td><StatusBadge status="success" label="Shared" /></td></tr></tbody>
</table>There are 96 line icons, all drawn the same way so they sit well together and take the colour of whatever they are in. Use them by name:
<Icon icon="refresh" /> // 16 px, in the text colour
<Icon icon="warning" size={20} className="text-amber-500" />
<IconTile icon="user" /> // on a small white tile, as in card titles
<IconTile icon="success" tone="text-green-600" />
<IconLabel icon="download">Download</IconLabel> // icon + text with the right gapThe names are shown in the picture above and on the demo's Icons page. Every component's icon option takes the same names.
The required credit, ready-made.
<Credits /> // the full block for an About page
<Credits compact /> // one short line, for a footer
<Credits extra={[{ label: 'Made for', name: 'Sample Shop', href: 'https://example.com' }]} />extra adds your own lines above the One UI ones.
For pages without Preact or Tailwind. Link the two files and write normal HTML:
<link rel="stylesheet" href="one-ui-plain.css">
<script src="one-ui-plain.js"></script>| Light | Dark, glass, amber |
|---|---|
![]() |
![]() |
Headings, paragraphs, links, code, form fields and checkboxes are styled as soon as the file is linked. For everything else, add these classes:
| Class | What it is |
|---|---|
oui-page |
Centres the page at a comfortable width. |
oui-header › oui-header-title, oui-header-actions |
The page heading with its buttons on the right. Put an oui-icon-tile with an icon inside the title. |
oui-card › oui-card-header, oui-card-body |
A card with a title bar. Add oui-glass for a see-through card. |
oui-tile › oui-tile-label, oui-tile-value |
One fact in a grey box. |
oui-stat › oui-stat-label, oui-stat-value |
A big number in a grey box. |
oui-btn + oui-btn-primary / -outline / -success / -danger |
Buttons. A plain oui-btn is the grey one. |
oui-badge + oui-badge-success / -warning / -error / -info |
Status words. |
oui-dot + oui-dot-success / -warning / -error |
A small status dot. |
oui-notice + oui-notice-success / -warning / -error |
Message boxes. |
oui-segmented |
A row of <button>s; give the chosen one aria-selected="true". |
oui-switch |
Turns a checkbox into an on/off switch. |
oui-progress |
A progress bar: <div class="oui-progress"><span style="width:40%"></span></div> |
oui-table |
A table with the One UI look. |
oui-stack · oui-grid · oui-row |
Spacing: cards stacked, cards in columns, items in a row. |
oui-muted · oui-small · oui-mono |
Grey text, small text, typewriter text. |
oui-credits |
The credit line. Required, see Credits. |
The script applies the remembered mode, colour and surface before the page appears, so it never flashes white. It also lets the page change them:
OneUI.setDark(true);
OneUI.setAccent('amber'); // red, blue, amber, graphite, green
OneUI.setGlass(true);If the app next to it stores its settings under other names, tell the script:
<script src="one-ui-plain.js" data-accent-key="myapp_accent" data-dark-key="myapp_dark"
data-default-accent="amber"></script>The full example is demo/plain.html.
src/ The preset (Preact + Tailwind)
one-ui.css colours, dark mode, glass, tables, scrollbars
theme.js the five colours and the light/dark/glass switches
useTheme.js all of the above for a Preact app, remembered
useVisiblePoll.js poll only while the page is on screen
styles.js inputClass, labelClass, hintClass
icons.jsx the icons, Icon, IconTile, IconLabel
components/ AppShell, Sidebar, AppearanceMenu, PageHeader, PageTabs, Card,
TabbedCard, Button, StatusBadge, ToggleSwitch, Segmented, Modal,
CopyField, Feedback (Loading, Empty, Notice, ProgressBar),
ContextMenu, StatsWidget, Credits
index.js everything, from one place: import { Card } from 'one-ui'
plain/ The plain version
one-ui-plain.css the stylesheet
one-ui-plain.js the theme script (built from entry.js, don't edit it)
icons/ App icons
app-icon.py a flat icon to frosted, frosted 3D or glyph 3D
demo/ The live demo: index.html and plain.html, built demo.js and demo.css
src/ the demo's sources, with its sample data
docs/renders/ The pictures in this README
NOTICE The credit requirement
npm install
npm run build # builds demo/demo.js and demo.css; open demo/index.html
npm run render # builds, then takes new pictures for docs/renders (needs Chrome or Edge)The demo's content is made up and different on every load. Add ?seed=7 to the address to get the same content every time; the pictures use seed 42. The address can also choose what is shown, e.g. index.html?page=forms&dark=1&accent=green&glass=1. On the Menus page &menu=1 opens a card's "…" menu and &ctx=1 a right-click menu; &widget=1 opens the host widget and &widget=0 leaves it out.
The live demo at https://auxxxilium.github.io/one-ui/demo/ is the demo/ folder exactly as it is in the repository: GitHub Pages serves the main branch, with this README as the start page. So after changing anything in src/, plain/ or demo/src/, run npm run build and commit the rebuilt demo/demo.js, demo/demo.css and plain/one-ui-plain.js with your change.
GPL-3.0. The credit requirement in NOTICE applies to every use.
One UI, developed by AuxXxilium, theme and design by Fulcrum.





















