# Orbit documentation, full text > Every page of https://zumerlab.com/orbit/ as plain text, with the code of its live examples. Generated by scripts/llms.mjs. --- Source: https://zumerlab.com/orbit/ # HTML on circles. Orbit is a CSS layout system for things that grow around a center: gauges, dials, knobs, radial menus, donut charts, watch faces. You write plain HTML with a few classes. Orbit does the trigonometry. [How Orbit thinks](https://zumerlab.com/orbit/universe.html) [See examples](https://zumerlab.com/orbit/examples/index.html) [Star on GitHub](https://github.com/zumerlab/orbit) `npm install @zumer/orbit` ## A small solar system Orbit borrows its words from the sky. It sounds like a gimmick for about a minute. After that it is the easiest way to remember what each piece does. - `.bigbang` The universe. Its box is the only size you set. - `.gravity-spot` The center. Its gravity reaches the edge of the universe. - `.orbit-1` … `.orbit-12` Twelve lanes from the center out to that edge. - `.satellite` Travels a lane. Orbit spreads them around it and keeps your content upright. ```html
72%uploaded
``` ```css body { display: grid; grid-template-rows: minmax(0, 1fr) auto; height: 100vh; padding: 20px 16px 14px; } .bigbang { max-height: 100%; } .controls { justify-content: center; } /* cqmin: a share of the satellite, so the number scales with the ring. */ .readout strong { font-size: 22cqmin; } o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; } ``` ```js const ring = document.querySelector('o-progress'); document.querySelector('input').addEventListener('input', (e) => { ring.setAttribute('value', e.target.value); ring.setAttribute('aria-valuenow', e.target.value); document.querySelector('strong').textContent = e.target.value + '%'; }); ``` A whole Orbit: a progress ring and a label. Nothing in it has a size in pixels, so the same markup works as a badge or a full-screen dial. Move the slider, or open the code and change it. ## Start somewhere - [How Orbit thinks](https://zumerlab.com/orbit/universe.html): The model in one page, with live examples. Read this first. - [Build a knob](https://zumerlab.com/orbit/guide.html): From an empty box to a control you can drag, step by step. - [Examples](https://zumerlab.com/orbit/examples/index.html): Full pages with live controls, and small layouts to copy: gauges, a radar, watches, depth in 3D. - [Orbit Kit](https://zumerlab.com/orbit/kit/index.html): Ready-made gauges, donuts, knobs and clocks, one tag each. - [Coming from 1.x](https://zumerlab.com/orbit/migrating.html): What changed in 2.0, and one line to keep everything as it was. Orbit is open source under the MIT license, made by [Zumerlab](https://github.com/zumerlab). If it saves you time, you can [sponsor its development](https://github.com/sponsors/tinchox5). Using Orbit 1.x? [Coming from 1.x](https://zumerlab.com/orbit/migrating.html) shows what changed. --- Source: https://zumerlab.com/orbit/universe.html # How Orbit thinks Every Orbit layout is a tiny universe. Once the four words in this page click, the rest of Orbit is details. ## The universe A `.bigbang` is the universe: the space your layout lives in. It is square by default, as wide as its parent, and a parent with a height keeps it from growing taller. Its box is the only size you ever set. Inside it, a `.gravity-spot` is the center. Its gravity reaches as far as the shorter side of the universe, and that reach is split into 12 lanes. `orbit-12` touches the edge, `orbit-6` is halfway, `orbit-1` hugs the center. ```html
orbit-6
orbit-12
``` ```css /* The universe, tinted so you can see it. */ .bigbang { background: color-mix(in srgb, var(--accent) 6%, var(--panel)); } /* Rings are invisible; these borders only draw the lanes. */ [class*="orbit-"] { border: 1px dashed color-mix(in srgb, var(--faint) 70%, transparent); } .half, .edge { border: 1.5px solid var(--accent); } .tag { padding: 2px 7px; border-radius: 6px; background: var(--bg); color: var(--accent); font: 12px var(--mono); white-space: nowrap; } ``` The light area is the universe. The preview is wider than it is tall, so the shorter side, 300px, sets the reach. There is also `orbit-0`, the center itself, and `orbit-13` to `orbit-24` past the edge, for things that should stick out. ## Things on the lanes Rings hold things. A `.satellite` carries your content in a `.capsule`; Orbit spreads all the satellites of a ring evenly around it, and the capsule keeps the content upright wherever it ends up. Every element on a ring is **one lane thick**. A satellite on `orbit-8` is a circle one lane across. To make things bigger or smaller, set `--o-size-ratio`, counted in lanes, on the element, on its ring or on the whole `.gravity-spot`: ```html
1
1
1
2
2
2
2
3
3
3
3
3
``` ```css [class*="orbit-"] { border: 1px dashed var(--faint); } .satellite { border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px var(--bg); color: var(--bg); font: 600 12px/1 var(--mono); } /* The number is the thickness, in lanes. */ .orbit-7 .satellite { font-size: 14px; } .orbit-10 .satellite { font-size: 18px; } ``` An element on `orbit-N` reaches half its thickness past lane N, so a band two lanes thick on `orbit-11` ends exactly at the edge. ## One box, any size Because every ring, satellite and band is a fraction of the universe, a layout scales as one piece. You do not write breakpoints for it, and you do not call anything. Drag the corner of the dotted box: ```html
64
``` ```css /* Drag the corner: the system follows the box. */ .box { width: 220px; height: 220px; margin: 20px; resize: both; overflow: hidden; border: 1px dashed var(--faint); border-radius: 10px; } o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; } .orbit-6 .satellite { border-radius: 50%; background: var(--amber); } ``` If you need a fixed size instead, give the reach a length on the `.gravity-spot`: `--o-force: 400px`. ## Universes inside universes A satellite is a universe too. Put a `.gravity-spot` inside one and its reach is the satellite's edge, like a moon system around a planet. Every `.gravity-spot` starts from the defaults, so the inner system does not inherit sizes or angles from the outer one. ```html
``` ```css .satellite { border-radius: 50%; } .sun { background: var(--amber); box-shadow: 0 0 24px color-mix(in srgb, var(--amber) 60%, transparent); } .path { border: 1px dashed var(--line); } .moons { border: 1px dashed var(--faint); } .planet { background: var(--accent); } .moon { background: var(--muted); } /* Turning a ring turns everything on it, inner systems included. */ .path { animation: turn 40s linear infinite; } .moons { animation: turn 6s linear infinite; } @keyframes turn { to { rotate: 1turn; } } ``` Each planet is a satellite of the sun's `orbit-8`, five lanes thick. Inside it, a whole new system: the planet in its center and its moons on its own `orbit-10`, measured against the planet's satellite, not against the page. ## Angles Angles start at the top and grow clockwise. `--o-range` is how much of the circle a ring uses and `--o-from` where it starts. A speedometer is a ring that uses 240° and starts at 240°, so its gap sits at the bottom: ```html
240°from 240°
``` ```css /* The full circle, for reference: the unused 120° is the gap. */ .whole { border: 1px dashed var(--line); } o-arc { --o-gap: 3; --o-stroke: none; } .ok { --o-fill: var(--accent); } .warn { --o-fill: var(--amber); } .bad { --o-fill: var(--coral); } .vector { height: 2px; background: var(--faint); } .readout strong { font-size: 30px; } ``` Without `value`, sibling arcs share the ring equally. `fit-range` puts the first and last tick on the two ends of the range instead of leaving a step at the end. Classes like `range-240`, `from-240` and `ccw` cover the usual values. ## Which way is out Each satellite also knows where it sits: `--o-dir-x` and `--o-dir-y` are its direction from the center, from `-1` to `1` (`y` grows down). Use them to hang labels outward, here above the satellites of the upper half and below the rest: ```html
Mercury
Venus
Earth
Mars
Jupiter
Saturn
``` ```css .orbit-8 { border: 1px dashed var(--line); } .capsule { aspect-ratio: 1; } .dot { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--accent); } .label { /* -1 in the upper half, 1 in the lower half */ --side: clamp(-1, var(--o-dir-y) * 1000, 1); position: absolute; left: 50%; top: calc(50% + var(--side) * (50% + 4px)); translate: -50% calc((var(--side) - 1) * 50%); font-size: 13px; white-space: nowrap; } .controls { position: fixed; left: 16px; bottom: 14px; } ``` ```js // Move the ring's start angle: labels switch sides as they cross the middle. const ring = document.querySelector('.orbit-8'); const input = document.querySelector('input'); input.addEventListener('input', () => { ring.style.setProperty('--o-from', input.value + 'deg'); document.querySelector('output').value = input.value + '°'; }); ``` ## The whole map ``` .bigbang the universe: its box sets the size └── .gravity-spot the center; reaches the shorter side ├── .orbit-N a lane, N twelfths of the way out │ ├── .satellite > .capsule ← your content │ ├── .vector · .side rays and chords │ └── · └── .orbit-M … ``` - Rings are direct children of a `.gravity-spot`, never of another ring. - A ring holds only `.satellite`, `.vector`, `.side`, `` and ``. - Content goes in a `.capsule` inside a `.satellite`. Centered content goes in `.orbit-0 > .satellite.at-center > .capsule`. Add `dev-orbit` to the `.bigbang` while you build: it draws the lanes, outlines anything in the wrong place and explains it in the console. More in [Visual aids](https://zumerlab.com/orbit/craft/visual-aids.html). --- Source: https://zumerlab.com/orbit/install.html # Install Orbit is two files: a stylesheet and a small runtime. Load both. The CSS does the geometry and the look; the runtime registers `` and ``, numbers the rings and keeps everything drawn as your layout changes. ## From npm ```sh npm install @zumer/orbit ``` Then, in the entry file of your app: ```js import '@zumer/orbit/style'; import '@zumer/orbit'; ``` The package is safe to import during server rendering. Import it in the browser entry too, so it can register the elements and watch the page. It exports `Orbit`, `OrbitArc`, `OrbitProgress` and `registerOrbit`; registration happens on import. ## From a CDN Load it from unpkg. This always gets the latest release; add a version after the name (`@zumer/orbit@2`) if you want it to change only when you decide. ```html ``` The script puts the API on `window.Orbit`. To load it as an ES module instead: `import { Orbit } from 'https://unpkg.com/@zumer/orbit/dist/orbit.mjs'`. ## What is in the package Everything lives in `dist/`. You can [browse it on unpkg](https://unpkg.com/browse/@zumer/orbit/dist/). | File | Import as | What it is | | `orbit.css`, `orbit.min.css` | `@zumer/orbit/style` | The stylesheet: geometry, reset, default theme, `theme-cyan`, the color palette and `dev-orbit`. About 18 kB gzipped. | | `orbit.mjs` | `@zumer/orbit` (import) | The runtime as an ES module. | | `orbit.js`, `orbit.min.js` | `@zumer/orbit` (require) | The runtime as a classic script that sets `window.Orbit`. About 9 kB gzipped. | | `orbit.d.ts`, `style.d.ts` | | TypeScript types for the API, the elements and the stylesheet import. | | `react.d.ts` | `@zumer/orbit/react` | JSX types for `` and ``. Types only, no code. See [React](https://zumerlab.com/orbit/frameworks/react.html). | | `custom-elements.json` | `@zumer/orbit/custom-elements.json` | A [Custom Elements Manifest](https://custom-elements-manifest.open-wc.org/) for tools that read one. | | `vscode.html-custom-data.json` | `@zumer/orbit/vscode.html-custom-data.json` | Attribute completion for VS Code. | To get that completion, add this to `.vscode/settings.json`: ```json { "html.customData": ["./node_modules/@zumer/orbit/dist/vscode.html-custom-data.json"] } ``` ## Try it A layout is a `.bigbang` with a `.gravity-spot` inside, and rings inside that. Put things on the rings: satellites with your content, arcs, progress, ticks. This one is a ring of eight satellites around a label. Edit it here, or press *Copy page* and save it as an `index.html`: it loads Orbit from unpkg and needs nothing else. ```html
Orbit
1
2
3
4
5
6
7
8
``` ```css .orbit-8 { border: 1px dashed var(--faint); } .orbit-8 .satellite { border-radius: 50%; background: color-mix(in srgb, var(--accent) 16%, var(--panel)); box-shadow: inset 0 0 0 1.5px var(--accent); color: var(--accent); font: 600 15px var(--mono); transition: background .2s, color .2s; } .orbit-8 .satellite:hover { background: var(--accent); color: var(--bg); } .label { font-size: 24px; font-weight: 650; letter-spacing: -.02em; } ``` ## When something looks wrong Orbit explains common mistakes in the browser console, in messages that start with `[orbit]`. Messages about structure appear when `dev-orbit` is on the `.bigbang`; the others always appear. [Visual aids](https://zumerlab.com/orbit/craft/visual-aids.html) shows what `dev-orbit` draws. | You see | Because | Fix | | Nothing, and the console says *has no size* | The `.bigbang` is 0px wide or tall, usually in a parent that shrinks to fit. | Give the `.bigbang` or its parent a width. | | A layout as big as the window | The `.gravity-spot` is not inside a `.bigbang`, so it measures the viewport. | Wrap it in a `.bigbang`. | | An empty arc or ring | `value` is not a number, or `max` is not positive. | Fix the attribute; the console names it. | | Content overlapping or off the ring | It sits directly in a ring instead of a `.satellite`. | Add `dev-orbit` to see where, then wrap it. | | Curved text cut off | The text is longer than its arc. | Shorten it, widen `--o-range`, use `range="auto"`, or `fit-range`. | | Everything thin | Elements are one lane thick. | Raise `--o-size-ratio` on the ring or the system. | | An arc that ignores clicks | Arcs are decorative by default. | Add `interactive`, and `hit-width` for a bigger target. | | The console says *Orbit.resize() has nothing to do here* | The system already scales with its `.bigbang`. | Remove the call. See [Runtime and resizing](https://zumerlab.com/orbit/craft/runtime.html). | ## Browser support Orbit needs CSS `sin()`, `cos()` and container query units, which current versions of Chrome, Edge, Firefox and Safari all have. In a browser without trigonometric functions, each `.bigbang` turns into a red box that asks for an update. Without JavaScript, a CSS fallback still places simple layouts: up to 24 rings and 60 children of each kind per ring. The fallback also needs `:has()` and `:nth-child(… of …)`. `` and `` always need the runtime. ## Next - [How Orbit thinks](https://zumerlab.com/orbit/universe.html) explains the model in one page. - [Build a knob](https://zumerlab.com/orbit/guide.html) puts it to work step by step. - With a framework: [React](https://zumerlab.com/orbit/frameworks/react.html), [Vue](https://zumerlab.com/orbit/frameworks/vue.html), [Svelte](https://zumerlab.com/orbit/frameworks/svelte.html), [Angular](https://zumerlab.com/orbit/frameworks/angular.html), [Astro](https://zumerlab.com/orbit/frameworks/astro.html). --- Source: https://zumerlab.com/orbit/guide.html # Build a knob A volume knob: a ring of ticks, a band that shows the level, a body with a dot that turns, and the number under it. On the way you meet every piece of Orbit. Each step below is live: open its code and change it. ## 1. Make a universe Everything starts with a [`.bigbang`](https://zumerlab.com/orbit/reference/bigbang.html). It is the space the knob lives in, and its box is the only size you set: the knob will be as big as this box. Here it is 260px wide and square by default. It is invisible; the dashed outline is only there so you can see it. ```html
``` ```css body { display: grid; place-items: center; } /* The only size you set. */ .knob { width: 260px; outline: 1px dashed var(--faint); } ``` ## 2. Give it a center A [`.gravity-spot`](https://zumerlab.com/orbit/reference/gravity-spot.html) is the center of the system. Its gravity reaches the shorter side of the universe, and that reach is split into 12 lanes: the rings you will place things on. It is invisible too. The circle drawn here is its reach, `--o-force`. ```html
``` ```css body { display: grid; place-items: center; } .knob { width: 260px; outline: 1px dashed var(--faint); } /* Only to see it: the reach of the gravity spot. */ .gravity-spot::before { content: ''; position: absolute; left: 0; top: 0; translate: -50% -50%; width: var(--o-force); aspect-ratio: 1; border-radius: 50%; border: 1px dashed var(--accent); } ``` ## 3. Draw the orbits [Rings](https://zumerlab.com/orbit/reference/orbit.html) go straight inside the gravity spot. `orbit-N` sits N lanes from the center: `orbit-6` halfway, `orbit-12` at the edge. The knob needs three: `orbit-11` for the ticks, `orbit-10` for the band, and `orbit-0`, the center itself, for the body. Rings are transparent. The dashed lines here come from a `border` in the example CSS. ```html
``` ```css body { display: grid; place-items: center; } .knob { width: 260px; } /* Only to see them. */ .orbit-11, .orbit-10 { border: 1px dashed var(--accent); } ``` `orbit-11` outside, `orbit-10` just inside it. `orbit-0` is the center point, with no size of its own. ## 4. Put things on them Rings hold [satellites](https://zumerlab.com/orbit/reference/satellite.html) carrying your content, [ticks](https://zumerlab.com/orbit/reference/vector.html), [chords](https://zumerlab.com/orbit/reference/side.html), [arcs](https://zumerlab.com/orbit/reference/arc.html) and [progress bands](https://zumerlab.com/orbit/reference/progress.html), and Orbit spreads them evenly around their ring. Here: 21 ticks on `orbit-11`, a progress band on `orbit-10` and the number in the middle. The ticks are the same element 21 times, so one line of JavaScript writes them. Centered content goes in `.orbit-0 > .satellite.at-center > .capsule`. Every element on a ring is one lane thick, so the center satellite gets `--o-size-ratio: 6` to make room for the number. ```html
64volume
``` ```css body { display: grid; place-items: center; } .knob { width: 260px; } .orbit-11, .orbit-10 { border: 1px dashed var(--faint); } .ticks .vector { background: var(--muted); } o-progress { --o-fill: var(--accent); --o-stroke: none; } .readout strong { font-size: 26px; } ``` ```js document.querySelector('.ticks').innerHTML = '
'.repeat(21); ``` ## 5. Shape it Now the details. The ticks and the band use 270° of the circle starting at 225° (`range-270 from-225`), so the knob has a gap at the bottom, and `fit-range` puts the first and last tick on the two ends of the range. `--o-size-ratio` makes the ticks a little shorter and the band a little thinner than a lane, and `shape="rounded"` rounds the band's ends. The track behind the band is `--o-back-fill`. The number moves into the gap: a satellite on `orbit-11` with `angle-180` sits straight down. The dashed guides are gone. ```html
64volume
``` ```css body { display: grid; place-items: center; } .knob { width: 260px; } .ticks .vector { background: #3a4757; } o-progress { --o-stroke: none; --o-fill: var(--accent); --o-back-fill: #1d2630; } .readout strong { font-size: 22px; } ``` ```js document.querySelector('.ticks').innerHTML = '
'.repeat(21); ``` ## 6. A system inside a satellite A satellite is a universe too: put a `.gravity-spot` inside it and its rings are measured against the satellite. The body of the knob is a satellite 15 lanes wide on `orbit-0`. Its capsule paints the body, and the system inside it carries the dot on its own `orbit-9`, with the same range as the dial. With one satellite on a partial range, the dot sits at the start, 225°. Turning that inner ring with `rotate` moves it along: 64% of 270° is about 173°. ```html
64volume
``` ```css body { display: grid; place-items: center; } .knob { width: 260px; } .ticks .vector { background: #3a4757; } o-progress { --o-stroke: none; --o-fill: var(--accent); --o-back-fill: #1d2630; } .body { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 50% 30%, #2b3542, #151b22 70%); box-shadow: 0 14px 30px -10px #000, inset 0 1px 0 #3b4655, inset 0 -6px 14px #0a0d11; } .dot { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent); } /* 64% of the 270° range. */ .turn { rotate: 173deg; } .readout strong { font-size: 22px; } ``` ```js document.querySelector('.ticks').innerHTML = '
'.repeat(21); ``` ## 7. Make it turn The drawing is done; the rest is a few lines of JavaScript. One function sets the value: it changes `value` on the ``, turns the inner ring through a `--turn` custom property, lights the ticks up to the value and keeps `aria-valuenow` in sync. The pointer, the wheel and the arrow keys all call it. The `.bigbang` is the control: `role="slider"`, a name and `tabindex="0"`, so it works with a keyboard and a screen reader. Orbit draws; the behavior is yours. ```html
64volume
``` ```css body { display: grid; place-items: center; } .knob { width: 300px; touch-action: none; cursor: grab; } .knob:active { cursor: grabbing; } .knob:focus-visible { outline: none; } .ticks .vector { background: #3a4757; } .ticks .vector.lit { background: var(--accent); } o-progress { --o-stroke: none; --o-fill: var(--accent); --o-back-fill: #1d2630; } .body { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 50% 30%, #2b3542, #151b22 70%); box-shadow: 0 14px 30px -10px #000, inset 0 1px 0 #3b4655, inset 0 -6px 14px #0a0d11; } .knob:focus-visible .body { box-shadow: 0 0 0 3px var(--accent), 0 14px 30px -10px #000; } .dot { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent); } .turn { rotate: var(--turn, 0deg); transition: rotate .08s linear; } .readout strong { font-size: 24px; } ``` ```js const knob = document.querySelector('.knob'); const ticksRing = knob.querySelector('.ticks'); ticksRing.innerHTML = '
'.repeat(21); const ticks = [...ticksRing.children]; let value = 0; const set = (v) => { value = Math.max(0, Math.min(100, Math.round(v))); knob.querySelector('o-progress').setAttribute('value', value); knob.style.setProperty('--turn', value * 2.7 + 'deg'); knob.setAttribute('aria-valuenow', value); document.getElementById('level').textContent = value; ticks.forEach((tick, i) => tick.classList.toggle('lit', value > 0 && i * 5 <= value)); }; // Pointer angle around the center, 0 at the start of the 270° range. const fromPointer = (event) => { const box = knob.getBoundingClientRect(); const x = event.clientX - (box.left + box.width / 2); const y = (box.top + box.height / 2) - event.clientY; const along = (Math.atan2(x, y) * 180 / Math.PI + 360 + 135) % 360; if (along <= 270) set(along / 2.7); }; knob.addEventListener('pointerdown', (e) => { knob.setPointerCapture(e.pointerId); fromPointer(e); }); knob.addEventListener('pointermove', (e) => { if (knob.hasPointerCapture(e.pointerId)) fromPointer(e); }); knob.addEventListener('wheel', (e) => { e.preventDefault(); set(value - Math.sign(e.deltaY) * 2); }, { passive: false }); knob.addEventListener('keydown', (e) => { const step = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 }[e.key]; if (step) { e.preventDefault(); set(value + step); } if (e.key === 'Home') set(0); if (e.key === 'End') set(100); }); set(64); ``` Drag around the knob, scroll over it, or focus it and use the arrow keys. Change `width` on `.knob` and all of it scales together, ticks, band, body and dot. Every element has more options like these: the pages under Elements list them. The same knob is also a [full-page demo](https://zumerlab.com/orbit/demos/05-knob.html), and [``](https://zumerlab.com/orbit/kit/knob.html) in Orbit Kit is a ready-made one. --- Source: https://zumerlab.com/orbit/migrating.html # Coming from 1.x Orbit 2 changes one big thing: where size comes from. Everything else is smaller and mostly in your favor. You can move a layout over in a few minutes, or keep it exactly as it was with one line of CSS. The 1.x source and README stay on GitHub, at the [v1.6.0 tag](https://github.com/zumerlab/orbit/tree/v1.6.0#readme). ## Size comes from the universe In 1.x every system was 500px across, whatever the size of its `.bigbang`. `orbit-12` was always 500px wide, and to make a layout follow its container you called `Orbit.resize()`. In 2.0 the `.bigbang` is the universe and its box sets the size. The `.gravity-spot` reaches the shorter side of it (`--o-force` defaults to `100cqmin`), so `orbit-12` touches the edge and every ring is a fraction of the box. Layouts follow their container with no code. Both previews below are the same ring in a 300px-tall frame. The first keeps the 1.x size with a fixed reach; the second is written for 2.0. ```html
70%1.x size
``` ```css /* The reach of the system, drawn so you can compare. */ .reach { border: 1px dashed var(--faint); } o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; } .readout strong { font-size: 26px; } ``` `--o-force: 500px`: the dashed `orbit-12` is 500px across, larger than the frame, as in 1.x. ```html
70%2.0 size
``` ```css /* The reach of the system, drawn so you can compare. */ .reach { border: 1px dashed var(--faint); } o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; } .readout strong { font-size: 26px; } ``` No `--o-force`: the dashed `orbit-12` touches the edge of the frame, and `orbit-7` lands where 1.x put `orbit-4`. ## Two ways to move **Keep the 1.x size.** Set the old reach back on your systems: ```css .gravity-spot { --o-force: 500px; } ``` Your layouts look exactly as before. `Orbit.resize()` still scales systems with a fixed px reach, so code that used it keeps working. **Adopt the new model.** Remove `--o-force` and `Orbit.resize()`, then pick rings by their share of the space. If your 1.x layout sat in a box `W` px wide: - a ring `orbit-N` lands near `orbit-(N × 500 / W)`, - a thickness ratio `r` lands near `--o-size-ratio: r × 500 / W`. Round to the nearest lane; if that is visibly off, try the neighbor or nudge with `--o-alignment` in px. When every ring of a system scales the same way, set `--o-size-ratio` once on the `.gravity-spot`. Give auto-numbered `.orbit` rings an explicit number while you are at it, so the result does not depend on DOM order. Text inside arcs keeps its font size, so on a smaller layout a label may no longer fit its arc. The console will tell you (`[orbit] text "…" overflows its arc`); widen the range, use `range="auto"`, or move the ring out a lane. ## Everything else - **The universe.** A `.bigbang` is square by default and a size container, so it is the containing block of `position: fixed` descendants. Directly in `` it is capped at the viewport height instead of forced to it, so your own `width` and `aspect-ratio` apply. - **Thickness.** `.satellite` no longer resets `--o-size-ratio`, so a value set on its ring reaches it. `shrink-N` on a ring only changes how close it sits to the previous lane; it does not shrink what is on it. - **Nested systems.** Every `.gravity-spot` starts from the defaults, including ring spacing, so a `shrink-N` around a satellite does not leak into the system inside it. - **Gaps and alignment.** `--o-gap` cascades from the ring and the system. `--o-aligment` still works; `--o-alignment` is the right spelling. - **Ranges.** `fit-range` on a ring of arcs no longer makes the arcs overflow the range. - **Styling.** Satellites have no border by default. The reset, the default theme and `theme-cyan` count only their own class for specificity, so a single class of yours wins without `!important`, also inside a `.capsule`. `dev-orbit` combines with a theme. - **Arcs and progress.** They are placed with `rotate` instead of `transform`, so a `transform` of yours adds to it. Endings (`rounded`, `arrow`…) scale with the band. `interactive` makes the whole band clickable, transparent or not, and `hit-width` adds touch area. `variant="stroke"` draws a 2px line over a gray track by default. - **New.** `range="auto"` and `text-anchor` for arcs that fit their text, `--o-dir-x` / `--o-dir-y` on satellites, depth layers, `Orbit.ready()`, the `o-rendered` event, `value` and `max` as properties on arcs and progress (so `[value]` bindings redraw), and `[orbit]` messages in the console. - **No `!important`.** Utility classes (`grow-*x`, `shrink-N`, `angle-N`, `ccw`, `fit-range`, `at-*`, `flip`…) no longer use it. An inline style or a more specific rule of yours wins over them. In 1.x some of them could only be beaten with another `!important`; those still work, but you can drop them. - **Renamed.** `from-1x` … `from-12x` are now `core-1` … `core-12`, so they don't read like `from-N`. The old names still work. - **Fixed.** `angle-N` on a `.side` points where it does on a satellite. Only real `angle-N` classes reset `--o-from`, not any class that contains "angle-". `--o-color` set on a ring or satellite gives it its own `--o-color-*` shades. --- Source: https://zumerlab.com/orbit/reference/bigbang.html # Bigbang Every Orbit layout starts with a big bang. The `.bigbang` creates the universe your system lives in, and its box is the only size you set: every ring, satellite and band inside is a fraction of it. ```html
orbit-12
orbit-6
``` ```css /* The universe box, tinted so you can see it. */ .bigbang { background: color-mix(in srgb, var(--accent) 7%, transparent); outline: 1px dashed color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: -1px; } .edge { border-color: var(--accent); } .half { border-color: var(--faint); } .orbit-9 { border-color: var(--line); } .label { white-space: nowrap; padding: 1px 6px; border-radius: 6px; background: var(--panel); font: 12px var(--mono); color: var(--muted); } .sun { background: var(--amber); box-shadow: 0 0 18px var(--amber); } .planet { background: var(--text); } .turn { animation: turn 40s linear infinite; } .turn.fast { animation-duration: 16s; } @keyframes turn { to { rotate: 360deg; } } ``` The tinted box is the universe. `orbit-12` touches its top and bottom, and `orbit-6` is halfway. ## How big it is A `.bigbang` is as wide as its parent and square by default. A parent with a definite height caps it, so in a wide, short container it becomes a wide, short box, and the system inside takes the shorter side. - To size it, give the `.bigbang` or its parent a width. Your own `width`, `height` or `aspect-ratio` always win, because the defaults have zero specificity. - Directly inside `` it is capped at the viewport height. The previews on this site are pages like that, which is why the universe above is as tall as the frame. - It is a size container: the `.gravity-spot` inside measures it with container query units. It is also the containing block of `position: fixed` descendants. - A `.bigbang` with no width or height draws nothing, and the console says so with an `[orbit]` message. Drag the corner of the dotted box. The system follows with no JavaScript: make the box wide and short and it takes the height, tall and narrow and it takes the width. ```html
``` ```css .box { position: relative; width: 320px; height: 180px; min-width: 60px; min-height: 60px; max-width: calc(100vw - 32px); max-height: calc(100vh - 32px); resize: both; overflow: hidden; border: 2px dotted var(--faint); border-radius: 4px; } .bigbang { background: color-mix(in srgb, var(--accent) 7%, transparent); } .orbit-12 { border-color: var(--accent); } .orbit-9 { border-color: var(--line); } .satellite { background: var(--accent); } .size { position: absolute; left: 8px; bottom: 6px; font: 12px var(--mono); color: var(--muted); font-variant-numeric: tabular-nums; } ``` ```js document.getElementById('dots').innerHTML = '
'.repeat(8); // Only to show the size of the box: the layout itself needs no script. const box = document.querySelector('.box'); new ResizeObserver(() => { document.getElementById('size').value = `${box.clientWidth} × ${box.clientHeight}`; }).observe(box); ``` ## Moving the center The universe centers its system. An alignment class on the `.bigbang` puts the center on an edge or a corner of the box instead, so only part of the system shows. It is the quick way to a quarter dial in a corner or a half gauge along an edge. ```html
``` ```css .grid { display: grid; grid-template-columns: repeat(3, 84px); justify-items: center; gap: 12px 44px; } .cell { display: grid; justify-items: center; gap: 6px; } .cell span { font: 12px var(--mono); color: var(--muted); white-space: nowrap; } .bigbang { width: 84px; background: color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 4px; overflow: hidden; } .orbit-6, .orbit-12 { border-color: var(--accent); } /* The center of the system. */ .center { background: var(--amber); } ``` ```js const places = ['at-top-left', 'at-top', 'at-top-right', 'at-center-left', 'at-center', 'at-center-right', 'at-bottom-left', 'at-bottom', 'at-bottom-right']; // The same system nine times; only the class on the .bigbang changes. document.getElementById('grid').innerHTML = places.map(place => `
${place}
`).join(''); ``` The amber dot is the center of the system. `.at-center` is the default. ## A universe inside a system A `.bigbang` placed directly in a `.gravity-spot` takes the reach of that system instead of measuring its own box. That is how a whole system becomes one layer of a [depth stack](https://zumerlab.com/orbit/craft/depth.html). ## Reference | Class or property | Default | What it does | | `.bigbang` | | Creates the universe. Holds one or more `.gravity-spot`. | | `width` / `height` / `aspect-ratio` | `100%` / `auto` / `1` | The size of the universe. Capped by a parent with a definite height, and by the viewport directly in ``. | | `.at-top-left` … `.at-bottom-right` | `.at-center` | Puts the center of the system on an edge or a corner of the box. | | `.dev-orbit` | | Draws the lanes and explains misplaced elements in the console. See [Visual aids](https://zumerlab.com/orbit/craft/visual-aids.html). | --- Source: https://zumerlab.com/orbit/reference/gravity-spot.html # Gravity spot The `.gravity-spot` is the center of mass of a system. Every ring is its direct child, and its gravity reaches as far as the shorter side of the universe. ```html
120px
``` ```css .stage { gap: 12px; align-content: center; } /* A wide universe, tinted so you can see its box. */ .stage > .bigbang { width: 100%; height: calc(100vh - 84px); aspect-ratio: auto; background: color-mix(in srgb, var(--accent) 6%, transparent); border-radius: 6px; } .gravity-spot > [class*="orbit-"] { border-color: var(--accent); } .gravity-spot > .orbit-9, .gravity-spot > .orbit-6 { border-color: color-mix(in srgb, var(--accent) 40%, transparent); } .satellite { background: var(--accent); } .fixed > [class*="orbit-"] { border: 1px dashed var(--amber); } .fixed .satellite { background: var(--amber); } ``` ```js const force = document.getElementById('force'); force.addEventListener('input', () => { document.getElementById('fixed').style.setProperty('--o-force', force.value + 'px'); document.getElementById('out').value = force.value + 'px'; }); ``` Two systems in one universe. The teal one uses the default reach and touches the top and bottom of the box; the amber one has `--o-force` in px and ignores the box. ## Reach The reach is `--o-force`, the diameter of `orbit-12`. Between the center and that edge there are 12 lanes. The default, `100cqmin`, is the shorter side of the nearest `.bigbang` or satellite, so the system follows its box as it resizes. Set it when you want something else: - a length, for a fixed size that ignores the box: `--o-force: 120px`; - `100cqw`, to reach the width of a wide universe instead of its height; - a fraction of the default, like `calc(100cqmin * .8)`, to leave a margin around the system. Systems with a fixed px reach can still scale with their container through `Orbit.resize()`. See [Runtime and resizing](https://zumerlab.com/orbit/craft/runtime.html). ## Every system starts fresh A `.gravity-spot` resets the reach, the angles, the thickness and the spacing to their defaults. A system nested in a satellite does not inherit any of them from the one around it. A satellite is a universe too: a `.gravity-spot` inside one reaches the satellite's edge, so its `orbit-12` is the satellite's outline. Here the outer system makes its planets five lanes thick, and each planet holds a system of its own whose moons start again from one lane and set their own size. ```html
``` ```css .gravity-spot > .orbit-8 { border-color: var(--line); } .sun { background: var(--amber); box-shadow: 0 0 24px color-mix(in srgb, var(--amber) 60%, transparent); } /* The planet's outline is the orbit-12 of the system inside it. */ .planet { background: color-mix(in srgb, var(--accent) 10%, var(--bg)); border: 1px solid var(--accent); } .moons { border: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent); animation: turn 12s linear infinite; } .moon { background: var(--accent); } @keyframes turn { to { rotate: 360deg; } } ``` Each planet holds a system of its own. Its moons are three lanes of the planet, not of the sun. ## An empty core `.core-1` to `.core-12` on a `.gravity-spot` leave an empty core in the middle, N lanes wide. With `core-4` the radius is split into 16 lanes instead of 12 and `orbit-N` lands on lane N + 4: rings sit closer together, further from the center, and `orbit-12` still touches the edge. Compare the cyan rings (default) with the purple ones (`core-4`), both `orbit-4`, `orbit-8` and `orbit-12`. In 1.x these classes were `from-1x` to `from-12x`. They still work. ```html
core-4
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .gravity-spot > * { border-color: color-mix(in srgb, var(--accent) 55%, transparent); } #cored > * { border: 1px dashed var(--amber); } ``` ```js const core = document.getElementById('core'), cored = document.getElementById('cored'); core.addEventListener('input', () => { cored.className = 'gravity-spot' + (core.value > 0 ? ' core-' + core.value : ''); document.getElementById('out').value = core.value > 0 ? 'core-' + core.value : 'no core'; }); ``` Teal: `orbit-4`, `orbit-8` and `orbit-12` with no core. Amber: the same rings in a system with an empty core. ## Rings off the center A `.gravity-spot` centers its rings on itself. An alignment class on it (`.at-top-left`, `.at-top`, `.at-bottom-right` and the rest) moves every ring toward that edge or corner, so the rings touch the center instead of surrounding it. Four systems in the same spot, one per corner: ```html
``` ```css .gravity-spot[class*="at-"] > * { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 6%, transparent); } .center { background: var(--amber); } ``` Every ring touches the center, the amber dot, instead of surrounding it. ## Reference Set these on the `.gravity-spot` to change the whole system, or on a ring or element for just that part. | Class or property | Default | What it does | | `--o-force` | `100cqmin` | Reach: the diameter of `orbit-12`. | | `--o-size-ratio` | `1` | Thickness of the elements on the rings, in lanes. | | `--o-range` | `360deg` | How much of the circle the rings use. See [Orbits](https://zumerlab.com/orbit/reference/orbit.html#angles). | | `--o-from` | `0deg` | Where the range starts, clockwise from the top. | | `--o-gap` | `1` | Space between sibling arcs. | | `--o-ellipse-x` / `--o-ellipse-y` | `1` | Stretch the rings into ellipses. See [Orbits](https://zumerlab.com/orbit/reference/orbit.html#ellipses). | | `.core-1` … `.core-12` | | An empty core N lanes wide; every ring moves N lanes out. Old name: `from-Nx`. | | `.at-top-left` … `.at-bottom-right` | `.at-center` | Move the rings toward an edge or a corner, touching the center. | | `.depth` | | Stacks the rings as 3D layers. See [Depth layers](https://zumerlab.com/orbit/craft/depth.html). | --- Source: https://zumerlab.com/orbit/reference/orbit.html # Orbits Gravity draws orbits. A ring, `.orbit-N`, is a lane around the gravity spot, N twelfths of the way out to the edge, and it is where satellites, vectors, sides, arcs and progress travel. ```html
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } #lanes > * { border-color: var(--line); } #lanes > .orbit-12 { border-color: var(--accent); } /* The chosen lane, filled so you can see its diameter. */ #lanes > .on { border: 2px solid var(--amber); background: color-mix(in srgb, var(--amber) 10%, transparent); } .dot { background: var(--amber); } ``` ```js const lanes = document.getElementById('lanes'), lane = document.getElementById('lane'); // orbit-1 … orbit-12, and a dot on orbit-0, the center. lanes.innerHTML = Array.from({ length: 12 }, (_, i) => `
`).join('') + '
'; const show = () => { const n = Number(lane.value); lanes.querySelectorAll(':scope > *').forEach(ring => ring.classList.toggle('on', ring.matches('.orbit-' + n) && n > 0)); document.getElementById('out').value = n ? `orbit-${n}: ${n}/12 of the reach` : 'orbit-0: the center'; }; lane.addEventListener('input', show); show(); ``` The 12 lanes. `orbit-6` is halfway and `orbit-12`, in teal, touches the edge of the universe. ## Which lane - The diameter of `orbit-N` is N/12 of the reach (`--o-force`): `orbit-3` is a quarter, `orbit-6` half, `orbit-12` all of it. - `orbit-0` is the center itself. It holds centered content; see The center. - `orbit-13` to `orbit-24` lie past the edge, for things that should stick out. With the runtime, higher numbers work too. - `.orbit` without a number takes the next lane in order: the first `.orbit` of a system is lane 1, the second lane 2. Numbered rings are not counted. Rings are direct children of a `.gravity-spot`, never of another ring. A ring holds only `.satellite`, `.vector`, `.side`, `` and ``. With `dev-orbit` on the `.bigbang`, the console points out anything else. ## Look Rings are invisible. Give them a border or a background to draw them. ```html
``` ```css /* A ring is a round box: borders, backgrounds and shadows draw it. */ .glow { border: 2px solid var(--accent); box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 50%, transparent), inset 0 0 18px color-mix(in srgb, var(--accent) 30%, transparent); } .dotted { border: 3px dotted var(--amber); } .filled { border: 1px dashed var(--accent); background: color-mix(in srgb, var(--accent) 18%, transparent); } ``` ## Thickness Everything on a ring is one lane thick: a twelfth of the radius. Set `--o-size-ratio`, in lanes, on the ring to change all its elements, on the `.gravity-spot` for the whole system, or on one element. An element on `orbit-N` reaches half its thickness past lane N, so a band two lanes thick on `orbit-11` ends exactly at the edge. ```html
2
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } [class*="orbit-"] { border-color: var(--line); } .satellite { background: var(--accent); } #b .satellite { background: var(--amber); } ``` ```js const fill = (id, count) => document.getElementById(id).innerHTML = '
'.repeat(count); fill('a', 3); fill('b', 4); fill('c', 6); const size = document.getElementById('size'); size.addEventListener('input', () => { document.getElementById('b').style.setProperty('--o-size-ratio', size.value); document.getElementById('out').value = size.value; }); ``` `orbit-4` at the default, `orbit-10` at half a lane, and `orbit-7` at the thickness you pick. ## Spacing `.shrink-5` to `.shrink-100`, in steps of 5, move a ring toward the previous lane: `orbit-10 shrink-40` sits 40% of a lane inside lane 10. On a ring, `shrink-N` only changes where it sits; the elements on it keep their size. ```html
orbit-10
orbit-9
shrink-40
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: 100%; height: calc(100vh - 84px); aspect-ratio: auto; overflow: hidden; } .lane { border-color: var(--faint); } #ring { border: 2px solid var(--amber); } #ring .satellite { background: var(--accent); } .label { white-space: nowrap; padding: 1px 6px; border-radius: 6px; background: var(--panel); font: 12px var(--mono); color: var(--muted); } ``` ```js const shrink = document.getElementById('shrink'), ring = document.getElementById('ring'); shrink.addEventListener('input', () => { const n = Number(shrink.value); ring.className = 'orbit-10 range-100 from-310 fit-range' + (n ? ' shrink-' + n : ''); document.getElementById('out').value = n ? 'shrink-' + n : 'no shrink'; }); ``` The amber ring is `orbit-10` with `shrink-N`: at 100 it reaches `orbit-9`. Its satellites keep their size. ## Angles A ring spreads its elements evenly. Five satellites on a full ring sit 72° apart, starting at the top and going clockwise. Each kind is spread on its own, so five satellites and twelve vectors can share a ring. - `--o-range` is how much of the circle the ring uses (`360deg` by default). `.range-0` to `.range-360` set it in whole degrees. - `--o-from` is where the range starts, clockwise from the top. `.from-0` to `.from-360` set it. - `.fit-range` puts the first and last element on the two ends of a partial range, instead of leaving one step free at the end. - `.ccw` goes counterclockwise. All of them cascade, so you can set them on the `.gravity-spot` for every ring at once. To place one element at an exact angle, use `.angle-N` on it; see [Satellites](https://zumerlab.com/orbit/reference/satellite.html#angle). ```html
``` ```css .stage { gap: 10px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 120px); } #ring { border: 1px dashed var(--line); } .band o-arc { --o-fill: color-mix(in srgb, var(--accent) 14%, transparent); } .satellite { background: var(--accent); } .capsule { color: var(--bg); font-weight: 650; font-variant-numeric: tabular-nums; } #classes { color: var(--muted); } input[type=checkbox] { accent-color: var(--accent); } ``` ```js const ring = document.getElementById('ring'), band = document.getElementById('band'); ring.innerHTML = [1, 2, 3, 4, 5].map(n => `
${n}
`).join(''); const $ = id => document.getElementById(id); const set = () => { const range = Number($('range').value), from = Number($('from').value), ccw = $('ccw').checked; // The ring: what you would write in your markup. ring.className = `orbit-9 range-${range} from-${from}` + ($('fit').checked ? ' fit-range' : '') + (ccw ? ' ccw' : ''); // The band always runs clockwise, so a ccw range starts `range` degrees earlier. band.style.setProperty('--o-range', range + 'deg'); band.style.setProperty('--o-from', (ccw ? from - range : from) + 'deg'); $('classes').textContent = ring.className; }; document.querySelectorAll('input').forEach(input => input.addEventListener('input', set)); set(); ``` Five satellites and the range they share. Try `range-180` with and without `fit-range`. ## The center `orbit-0` is the center of the system. Put a `.satellite.at-center` in it for centered content, and size it with `--o-size-ratio` on `orbit-0`: the value is its diameter in lanes, so `12` reaches `orbit-6` and `24` fills the universe. ```html
orbit-12
orbit-6
16
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .guide { border: 1px dashed var(--faint); z-index: 1; } .disc { background: color-mix(in srgb, var(--accent) 22%, transparent); border: 2px solid var(--accent); } .label { white-space: nowrap; padding: 1px 6px; border-radius: 6px; background: var(--panel); font: 12px var(--mono); color: var(--muted); } ``` ```js const size = document.getElementById('size'); size.addEventListener('input', () => { document.getElementById('center').style.setProperty('--o-size-ratio', size.value); document.getElementById('out').value = size.value; }); ``` At 12 the disc reaches `orbit-6`; at 24 it fills the universe. ## Ellipses `--o-ellipse-x` and `--o-ellipse-y` stretch a ring into an ellipse. Values below 1 stretch it along that axis: `--o-ellipse-x: .5` makes it twice as wide. Satellites and vectors follow the ellipse; arcs, progress and sides are drawn for circles, so leave them on round rings. ```html
0.5 1
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .round { border: 1px dashed var(--faint); } .stretched { border: 1px solid var(--accent); } .satellite { background: var(--accent); } ``` ```js const ring = document.getElementById('ring'); ring.innerHTML = '
'.repeat(8); for (const axis of ['x', 'y']) { const input = document.getElementById(axis); input.addEventListener('input', () => { ring.style.setProperty('--o-ellipse-' + axis, input.value); document.getElementById(axis + '-out').value = Number(input.value); }); } ``` Eight satellites on a stretched `orbit-6`, over the round one. ## Gooey effect `.gooey-fx` (or `.gooey-fx-light`), `.gooey-fx-medium` and `.gooey-fx-max` blend touching elements of a ring into one blob with an SVG filter. Safari does not support it. ```html
3.5
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .track { border-color: var(--line); } .satellite { background: var(--accent); } .blob { animation: turn 8s linear infinite; } input[type=checkbox] { accent-color: var(--accent); } @keyframes turn { to { rotate: 360deg; } } ``` ```js const ring = document.getElementById('ring'), size = document.getElementById('size'); size.addEventListener('input', () => { ring.style.setProperty('--o-size-ratio', size.value); document.getElementById('out').value = size.value; }); document.getElementById('gooey').addEventListener('change', (event) => ring.classList.toggle('gooey-fx', event.target.checked)); ``` ## Stacking Rings stack in document order: a later ring paints over an earlier one. To stack them in 3D, with perspective and animated depth changes, see [Depth layers](https://zumerlab.com/orbit/craft/depth.html). ## Reference | Class or property | Default | What it does | | `.orbit-0` … `.orbit-24` | | A ring on lane N. 0 is the center, 13 and up stick out. | | `.orbit` | | A ring on the next lane, counted in document order. | | `--o-size-ratio` | `1` | Thickness of the ring's elements, in lanes. | | `--o-range`, `.range-N` | `360deg` | How much of the circle the ring uses. | | `--o-from`, `.from-N` | `0deg` | Where the range starts, clockwise from the top. | | `.fit-range` | | First and last element on the two ends of the range. | | `.ccw` | | Counterclockwise. | | `.shrink-5` … `.shrink-100` | | Moves the ring toward the previous lane, by that percentage of a lane. | | `--o-ellipse-x` / `--o-ellipse-y` | `1` | Stretch the ring into an ellipse. | | `.gooey-fx`, `-medium`, `-max` | | Blends touching elements together. | --- Source: https://zumerlab.com/orbit/reference/satellite.html # Satellites Satellites travel the orbits. A `.satellite` sits on a ring and carries your content in a `.capsule`; Orbit spreads all the satellites of a ring evenly around it. ```html
5
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .orbit-8 { border-color: var(--faint); } .satellite { background: var(--accent); } .capsule { color: var(--bg); font-weight: 650; font-variant-numeric: tabular-nums; } ``` ```js const ring = document.getElementById('ring'), count = document.getElementById('count'); // Orbit spreads whatever is on the ring: change the count, the spacing follows. const fill = () => { ring.innerHTML = Array.from({ length: count.value }, (_, i) => `
${i + 1}
`).join(''); document.getElementById('out').value = count.value; }; count.addEventListener('input', fill); fill(); ``` A satellite is a circle one lane across, with no border or background until you give it one. Put content in a `.capsule` inside it, never directly in the satellite: the capsule keeps it centered and upright. See [Capsules](https://zumerlab.com/orbit/reference/capsule.html). ## Size Set `--o-size-ratio`, in lanes, on the satellite, its ring or the `.gravity-spot`. For one satellite there are also classes, which win over any ratio set around them: - `.shrink-5` to `.shrink-100` make it smaller than a lane: `shrink-30` is 0.7 lanes. - `.grow-1x` to `.grow-12x` make it 2 to 24 lanes. Decimals add fifths of a lane: `grow-1.2x` is 2.4 lanes. - `.grow-0.1x` to `.grow-0.9x` add tenths of a lane: `grow-0.5x` is 1.5 lanes. ```html
``` ```css .orbit-8 { border-color: var(--faint); } .satellite { background: var(--accent); } ``` Clockwise from the top: half a lane, one lane, 1.5, 2, 3 and 4 lanes. ## Shape and look Satellites are round. `.box` makes one square and `.rounded-box` a square with soft corners. Anything else is plain CSS: backgrounds, borders, images, SVG. ```html
``` ```css .orbit-8 { border-color: var(--faint); } .satellite { background: var(--accent); } /* Anything else is plain CSS. */ .sphere { background: radial-gradient(circle at 35% 30%, #fff, var(--accent) 35%, color-mix(in oklab, var(--accent) 35%, black)); box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 45%, transparent); } .outline { background: var(--bg); border: 2px dashed var(--accent); } ``` Clockwise from the top: round, `.box`, `.rounded-box`, a gradient and a border. ## Position on the lane A satellite is centered on its lane. `.inner-orbit` moves it in so its outer edge touches the lane, and `.outer-orbit` moves it out so its inner edge does. `.quarter-inner-orbit` and `.quarter-outer-orbit` move it part of the way. For any other offset, set `--o-alignment`: a positive length moves it in, a negative one out. ```html
``` ```css /* The lane on top, so you can see where each satellite sits. */ .orbit-8 { border: 2px solid var(--amber); z-index: 1; } .satellite { background: color-mix(in srgb, var(--accent) 30%, transparent); border: 1px solid var(--accent); } ``` Clockwise from the top: `outer-orbit`, `quarter-outer-orbit`, default, `quarter-inner-orbit`, `inner-orbit`. ## An exact angle `.angle-0` to `.angle-360` place a satellite at that angle, clockwise from the top, instead of its turn on the ring. It ignores the ring's `--o-from`. ```html
45°
angle-45
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .lane { border-color: var(--faint); } .satellite { background: var(--accent); } .capsule { color: var(--bg); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; } .vector { height: 2px; background: var(--amber); } ``` ```js const angle = document.getElementById('angle'); angle.addEventListener('input', () => { const n = angle.value; document.getElementById('moon').className = `satellite angle-${n}`; document.getElementById('hand').className = `vector angle-${n}`; document.getElementById('label').textContent = n + '°'; document.getElementById('out').value = 'angle-' + n; }); ``` ## Facing the center A satellite keeps its content upright wherever it is. With `.spin-lock` it turns with its angle instead, so the top of its content always points at the center, like the labels on a wheel. Add `.flip` to the capsule to make it point outward. Left, the default; right, `spin-lock`. ```html
default
spin-lock
``` ```css .pair { grid-auto-flow: column; justify-content: center; gap: 48px; } .cell { display: grid; justify-items: center; gap: 8px; } .cell .bigbang { width: min(210px, 38vw, 100vh - 70px); } .cell code { color: var(--muted); } .orbit-8 { border-color: var(--faint); } .satellite { background: color-mix(in srgb, var(--accent) 20%, var(--bg)); border: 1px solid var(--accent); } .capsule { color: var(--accent); font-size: 18px; font-weight: 700; } ``` ```js const arrows = cls => '
↑
'; document.getElementById('upright').innerHTML = arrows('').repeat(6); document.getElementById('locked').innerHTML = arrows('spin-lock').repeat(6); ``` Left, every arrow points up. Right, with `spin-lock`, every arrow points at the center. ## Which way is out Each satellite exposes `--o-dir-x` and `--o-dir-y`: the direction from the center of its system to the satellite, from -1 to 1. `x` grows to the right and `y` grows down, so a satellite at the top has `0` and `-1`, and a `.satellite.at-center` has both at `0`. Its content inherits them, so CSS can push a label outward whatever the angle: ```html
22px
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .bigbang { --push: 22px; } .orbit-7 { border-color: var(--faint); } .satellite { background: var(--accent); } /* --o-dir-x/-y point away from the center: move each label along them. */ .label { translate: calc(var(--o-dir-x) * var(--push)) calc(var(--o-dir-y) * var(--push)); font-size: 13px; font-weight: 600; color: var(--text); } ``` ```js const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; document.getElementById('ring').innerHTML = points.map(p => `
${p}
`).join(''); const push = document.getElementById('push'); push.addEventListener('input', () => { document.querySelector('.bigbang').style.setProperty('--push', push.value + 'px'); document.getElementById('out').value = push.value + 'px'; }); ``` Each label moves away from the center along its own direction. [How Orbit thinks](https://zumerlab.com/orbit/universe.html) shows the other common use: labels that hang above the satellites of the upper half and below the rest. ## A satellite is a universe Put a `.gravity-spot` directly inside a satellite and it reaches the satellite's edge: a moon system around a planet, a gauge inside a menu item. Make the satellite thicker to give it room. See [Gravity spot](https://zumerlab.com/orbit/reference/gravity-spot.html). ## Reference | Class or property | Default | What it does | | `--o-size-ratio` | `1` | Diameter, in lanes. | | `.shrink-5` … `.shrink-100` | | Smaller than a lane: `shrink-N` is 1 − N/100 lanes. | | `.grow-1x` … `.grow-12x`, `.grow-0.1x` … | | Bigger: `grow-Nx` is 2N lanes. | | `.box`, `.rounded-box`, `.circle` | round | Shape. | | `.inner-orbit`, `.outer-orbit`, `.quarter-inner-orbit`, `.quarter-outer-orbit` | | Moves it in or out from its lane. | | `--o-alignment` | `0px` | Any offset from the lane; positive is inward. | | `.angle-0` … `.angle-360` | | An exact angle, clockwise from the top. | | `.spin-lock` | | Turns with its angle instead of staying upright. | | `.at-center` | | In `orbit-0`: the satellite sits at the center. | | `.at-top-left` … `.at-bottom-right` | | Where the capsule sits inside the satellite. | | `--o-dir-x` / `--o-dir-y` | read only | Direction from the center, from -1 to 1. | --- Source: https://zumerlab.com/orbit/reference/capsule.html # Capsules A `.capsule` is the cargo a satellite carries: your content. It stays centered and upright however far around the ring the satellite travels. ```html
Text
72
``` ```css .orbit-8 { border-color: var(--faint); } .satellite { background: color-mix(in srgb, var(--accent) 10%, var(--bg)); border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent); } .capsule { color: var(--accent); font-size: 14px; } .capsule button { padding: 4px 8px; } .number strong { font-size: 18px; font-variant-numeric: tabular-nums; } ``` ```js const count = document.getElementById('count'); document.getElementById('add').addEventListener('click', () => { count.textContent = (Number(count.textContent) + 1) % 100; }); ``` Text, an SVG icon, a button and a number, each in a capsule. Any HTML goes in a capsule. It works inside a `.satellite` and inside a `.side`. Content placed directly in a satellite is not centered or kept upright, and with `dev-orbit` on the `.bigbang` the console says so. A capsule is as wide as its satellite and as tall as its content, so text wraps at the satellite's width. Give the capsule its own `width` for a label wider than the satellite. Like satellites, capsules have no border or background until you give them one. ## Where it sits in the satellite A capsule is centered in its satellite. An alignment class on the satellite moves it: `.at-top-left`, `.at-top`, `.at-top-right`, `.at-center-left`, `.at-center-right`, `.at-bottom-left`, `.at-bottom` or `.at-bottom-right`. `.at-center` is the exception: on a satellite it places the satellite itself at the center of the system. ## Turning the content `.flip` turns a capsule upside down, `.turn-left` a quarter turn left and `.turn-right` a quarter turn right. In a `.spin-lock` satellite the content turns with the satellite and points its top at the center; `.flip` makes it point outward. ```html
Orbit
right
flip
left
out
out
out
``` ```css .orbit-9, .orbit-4 { border-color: var(--faint); } .satellite { background: color-mix(in srgb, var(--accent) 14%, var(--bg)); color: var(--accent); font-size: 13px; font-weight: 600; } .inner .satellite { background: color-mix(in srgb, var(--amber) 14%, var(--bg)); color: var(--amber); } ``` Outer ring: the four orientations. Inner ring: `spin-lock` satellites with flipped capsules, reading outward. ## In a side In a `.side`, a capsule lies along the side and turns with it, which suits labels on the edges of a polygon. Labels on the lower edges read upright; `.flip` turns over the ones at the top, and `.horizontal` keeps a capsule level instead. ```html
flip
along
along
horizontal
along
flip
``` ```css .side { height: 2px; background: var(--faint); } .capsule span { padding: 2px 9px; border-radius: 10px; background: color-mix(in srgb, var(--accent) 16%, var(--bg)); color: var(--accent); font-size: 12px; white-space: nowrap; } ``` ## Reference | Class | Goes on | What it does | | `.capsule` | child of `.satellite` or `.side` | Holds content, centered and upright. As wide as its satellite. | | `.flip` | capsule | Upside down. | | `.turn-left` / `.turn-right` | capsule | A quarter turn. | | `.horizontal` | capsule in a side | Stays level instead of following the side. | | `.at-top-left` … `.at-bottom-right` | satellite | Moves the capsule inside the satellite. | | `.spin-lock` | satellite | The capsule turns with the satellite. See [Satellites](https://zumerlab.com/orbit/reference/satellite.html). | --- Source: https://zumerlab.com/orbit/reference/vector.html # Vectors A `.vector` is a ray pointing out from the center, laid across its ring. A ring of vectors is a scale; a long one is a needle. ```html
``` ```css .face { background: radial-gradient(circle at 50% 35%, #1a222c, #0e1318 72%); box-shadow: inset 0 0 0 1px var(--line); } .minutes .vector { background: var(--faint); } .hours .vector { height: 3px; background: var(--text); } /* A hand turns with its ring. */ .hand { rotate: var(--turn, 0deg); } #hour .vector { height: 5px; border-radius: 3px; background: var(--text); } #minute .vector { height: 3px; border-radius: 2px; background: var(--accent); } #second .vector { height: 1px; background: var(--amber); } .hub { background: var(--amber); } ``` ```js document.getElementById('minutes').innerHTML = '
'.repeat(60); document.getElementById('hours').innerHTML = '
'.repeat(12); const turn = (id, deg) => document.getElementById(id).style.setProperty('--turn', deg + 'deg'); const tick = () => { const now = new Date(), s = now.getSeconds(), m = now.getMinutes() + s / 60, h = now.getHours() % 12 + m / 60; turn('second', s * 6); turn('minute', m * 6); turn('hour', h * 30); document.querySelector('.clock').setAttribute('aria-label', now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })); }; tick(); setInterval(tick, 1000); ``` 60 and 12 ticks share `orbit-11`. The three hands are vectors on `orbit-3`, `orbit-4` and `orbit-5`, each turned with `rotate`. ## Length and look A vector is one lane long and 1px thick, in the text color. Its look is plain CSS: `height` for the thickness, `background` for the color, `border-radius` for round ends. For the length, set `--o-size-ratio` in lanes on the vector, its ring or the `.gravity-spot`, or use the classes: - `.shrink-5` to `.shrink-100`: shorter than a lane. `shrink-30` is 0.7 lanes. - `.grow-1x` to `.grow-12x`: 2 to 24 lanes, with decimals like `grow-1.5x` (3 lanes). `.grow-0.1x` to `.grow-0.9x` add tenths of a lane. Like every element on a ring, a vector is centered on its lane and reaches half its length to each side. So a vector `2N` lanes long on `orbit-N` starts at the center: that is the minute hand above, 8 lanes on `orbit-4`. ```html
``` ```css .orbit-8 { border: 1px dashed var(--faint); } .vector { height: 3px; border-radius: 2px; background: var(--accent); } /* The look is plain CSS. */ .thick { --o-size-ratio: 2; height: 8px; border-radius: 4px; background: var(--amber); } .dashed { --o-size-ratio: 3; border-radius: 0; background: repeating-linear-gradient(90deg, var(--amber) 0 5px, transparent 5px 9px); } ``` Clockwise from the top: half a lane, one, 1.5, 2, 3 and 4 lanes, then two styled with CSS. ## Position and angle `.inner-orbit` moves a vector in so it ends on its lane, and `.outer-orbit` moves it out so it starts there; `.quarter-inner-orbit` and `.quarter-outer-orbit` go part of the way, and `--o-alignment` takes any length. `.angle-0` to `.angle-360` point a vector at an exact angle, clockwise from the top. The ring's range, start and direction work as for satellites; see [Orbits](https://zumerlab.com/orbit/reference/orbit.html#angles). ```html
``` ```css .lane { border: 1px dashed var(--faint); } .lane .vector { height: 4px; border-radius: 2px; background: var(--accent); } .ticks .vector { height: 2px; background: var(--amber); } ``` ```js document.getElementById('ticks').innerHTML = '
'.repeat(7); ``` On `orbit-8`: `outer-orbit` at 0°, default at 90°, `inner-orbit` at 180°. On `orbit-11`: seven ticks over 90° from 225°. ## Reference | Class or property | Default | What it does | | `--o-size-ratio` | `1` | Length, in lanes. | | `height`, `background` | `1px`, `currentcolor` | Thickness and color. | | `.shrink-5` … `.shrink-100` | | Shorter: `shrink-N` is 1 − N/100 lanes. | | `.grow-1x` … `.grow-12x`, `.grow-0.1x` … | | Longer: `grow-Nx` is 2N lanes. | | `.inner-orbit`, `.outer-orbit`, `.quarter-inner-orbit`, `.quarter-outer-orbit` | | Moves it in or out from its lane. | | `--o-alignment` | `1px` | Any offset from the lane; positive is inward. | | `.angle-0` … `.angle-360` | | An exact angle, clockwise from the top. | --- Source: https://zumerlab.com/orbit/reference/side.html # Sides A `.side` is a straight line between two neighbors on a ring. Put N sides on a ring and they close a regular polygon with N corners. ```html
6
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .side { height: 2px; background: var(--accent); } .mid .side { background: color-mix(in srgb, var(--accent) 60%, transparent); } .inner .side { background: var(--amber); } ``` ```js const outer = document.getElementById('outer'), count = document.getElementById('count'); const draw = () => { outer.innerHTML = '
'.repeat(count.value); document.getElementById('out').value = count.value; }; count.addEventListener('input', draw); draw(); ``` Sides on `orbit-11`, four on `orbit-8` and three on `orbit-5`. A side is 1px thick, in the text color. Its look is plain CSS: `height` for the thickness and `background` for the color. Its length comes from the number of sides on the ring, so it has no size classes. ## Inside or around the ring By default the corners of the polygon sit on the ring. With `.outer-orbit` on every side, the middle of each side touches the ring instead, and the polygon wraps around it. ```html
``` ```css .ring { border: 1px dashed var(--faint); } .side { height: 2px; background: var(--accent); } .outer-orbit { background: var(--amber); } ``` Teal: corners on the ring. Amber: `.outer-orbit`, sides touching it. ## Partial polygons Sides follow the ring's `--o-range` and `--o-from`: three sides on `range-180` are half a hexagon. To put a single side at a given angle, use `.angle-N` on it, as with satellites: `angle-90` is the right edge. ```html
``` ```css .orbit-10, .orbit-6 { border: 1px dashed var(--line); } .side { height: 3px; background: var(--accent); } .four .side { background: var(--amber); } ``` Half a hexagon on `orbit-10` (`range-180 from-270`), and four sides over 240° on `orbit-6`. A side can carry a `.capsule` for a label along its edge. See [Capsules](https://zumerlab.com/orbit/reference/capsule.html). ## Reference | Class or property | Default | What it does | | `.side` | | One edge of a regular polygon, as long as the ring's range divided by the number of sides. | | `height`, `background` | `1px`, `currentcolor` | Thickness and color. | | `.outer-orbit` | | The side touches the ring at its middle, so the polygon wraps around it. | | `--o-range`, `--o-from`, `.ccw` | | From the ring. See [Orbits](https://zumerlab.com/orbit/reference/orbit.html#angles). | | `.angle-N` | | Puts the side at N degrees, clockwise from the top. | --- Source: https://zumerlab.com/orbit/reference/arc.html # Arcs `` draws a piece of a lane, with text along it if you want. Sibling arcs split their ring between them, or stack one after another by value. ```html
Segments
``` ```css .label o-arc { --o-fill: transparent; color: var(--muted); font-size: 13px; } .segments o-arc { --o-fill: color-mix(in srgb, var(--accent) 22%, transparent); --o-gap: 3; cursor: pointer; } .segments o-arc:hover { --o-fill: var(--accent); } .donut o-arc { --o-stroke: none; } .donut o-arc:nth-child(1) { --o-fill: var(--accent); } .donut o-arc:nth-child(2) { --o-fill: color-mix(in srgb, var(--accent) 55%, var(--bg)); } .donut o-arc:nth-child(3) { --o-fill: color-mix(in srgb, var(--accent) 25%, var(--bg)); } ``` ```js document.getElementById('segments').innerHTML = ''.repeat(8); ``` A label that fits its text, eight equal segments (hover them), and a donut of 45, 30 and 25. Arcs go straight into a ring, next to satellites or vectors if you like. They are drawn by the runtime, so they need the Orbit script. ## Reference | Attribute | Default | What it does | | `value` | | Share of the ring's range, out of `max`. Without it, arcs split the range equally. See Split or stack. | | `max` | `100` | The value that fills the whole range. | | `range` | | `auto` makes the arc as long as its text. See Arcs that fit their text. | | `shape` | `none` | The ending. See Endings. | | `text-anchor` | `middle` | Text along the arc: `start`, `middle` or `end`. On a `range="auto"` arc, where the arc sits around its angle. | | `flip` | | Runs the text the other way, so it reads upright at the bottom. Also works as a class. | | `fit-range` | | Stretches or squeezes the text to the whole arc. Also works as a class. | | `interactive` | | Makes the whole band clickable, even where it is transparent. See Pointer interaction. | | `hit-width` | `0` | Extra touch area, in px, on each side of an `interactive` band. | | Property or class | Default | What it does | | `--o-fill` | `var(--o-gray-light)` | Band color. `transparent` for text only. | | `--o-stroke` | `var(--o-fill)` | Outline color. | | `--o-stroke-width` | `1` | Outline width, in px. | | `color` or `--o-color` | `currentcolor` | Text color. Other font properties apply too. | | `--o-gap`, `.gap-0` … `.gap-30` | `1` | Space at each end of the arc. Cascades from the ring and the system. | | `--o-size-ratio` | `1` | Thickness, in lanes. `.grow-*` and `.shrink-*` work as on [satellites](https://zumerlab.com/orbit/reference/satellite.html). | | `.inner-orbit`, `.outer-orbit`, `.quarter-*-orbit` | | Moves the band in or out from its lane. | | `.angle-0` … `.angle-360` | | Starts the arc at that angle. | ## Split or stack Without `value`, sibling arcs share the ring equally: a segmented ring, or a pie when they are thick. With `value`, each takes its share of the range and starts where the previous one ended: a donut chart. Values are not normalized, so make them add up to `max` for a full ring. With `max="24"`, `value="6"` is a quarter. An explicit `value="0"` draws nothing and takes no room. ```html
50
25
15
``` ```css .slices o-arc { --o-stroke: var(--bg); --o-stroke-width: 2; } .capsule { color: var(--bg); font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; } ``` Five arcs with values 50, 25, 15, 5 and 5, and three satellites at the middle angle of the big ones. ## Color `--o-fill` colors the band, and `--o-stroke` and `--o-stroke-width` its outline. They change with a short transition, so a hover state is one rule. Hover needs `interactive`: arcs ignore the pointer until you ask. ```html
``` ```css o-arc { cursor: pointer; } o-arc.outlined { --o-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: var(--accent); --o-stroke-width: 2; } o-arc.plain { --o-fill: var(--line); } /* One rule per state: the colors change with a short transition. */ o-arc.outlined:hover { --o-fill: var(--accent); } o-arc.plain:hover { --o-fill: var(--amber); } ``` ```js document.getElementById('ring').innerHTML = ''.repeat(4); ``` Hover the segments. ## Endings `shape` sets how each end looks: `circle` (or `circle-a`), `circle-b`, `bullet`, `arrow`, `slash`, `backslash`, `rounded` and `zigzag`. Endings are sized from the band, so they look the same on every ring and at every size. ```html
``` ```css .grid { display: grid; grid-template-columns: repeat(auto-fit, 92px); justify-content: center; justify-items: center; width: min(100%, 560px); gap: 14px 24px; } .cell { display: grid; justify-items: center; gap: 4px; } .cell code { color: var(--muted); } .bigbang { width: 92px; } .gravity-spot { --o-size-ratio: 3; --o-gap: 6; } o-arc { --o-stroke: none; } o-arc:nth-child(1) { --o-fill: color-mix(in srgb, var(--accent) 35%, var(--bg)); } o-arc:nth-child(2) { --o-fill: color-mix(in srgb, var(--accent) 65%, var(--bg)); } o-arc:nth-child(3) { --o-fill: var(--accent); } ``` ```js const shapes = ['none', 'circle', 'circle-b', 'bullet', 'arrow', 'slash', 'backslash', 'rounded', 'zigzag']; // Three arcs per ring, all with the same ending. document.getElementById('grid').innerHTML = shapes.map(shape => `
${``.repeat(3)}
${shape}
`).join(''); ``` ## Thickness, position and gaps An arc is one lane thick. Set `--o-size-ratio` in lanes on the arc, its ring or the `.gravity-spot`, or use the `.grow-*` and `.shrink-*` classes on the arc. `.inner-orbit` and `.outer-orbit` move the band in or out so one of its edges sits on the lane, and the `.quarter-` versions go part of the way. `--o-gap` is the space left at each end of an arc. It cascades, so one value on the ring spaces all its arcs; `.gap-0` to `.gap-30` set it on one arc. A ring of 3 to 6 thick segments usually wants `--o-gap` between 24 and 60 to read as separate pieces. ```html
40
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } /* The lane on top, so you can see where each band sits. */ .lane { border: 2px solid var(--amber); z-index: 1; } .lane o-arc { --o-fill: color-mix(in srgb, var(--accent) 35%, transparent); --o-stroke: none; } .gaps o-arc { --o-fill: var(--accent); --o-stroke: none; } ``` ```js const gap = document.getElementById('gap'); gap.addEventListener('input', () => { document.getElementById('gaps').style.setProperty('--o-gap', gap.value); document.getElementById('out').value = gap.value; }); ``` Outer ring, clockwise from the top: `outer-orbit`, `quarter-outer-orbit`, default, `quarter-inner-orbit`, `inner-orbit`. Inner ring: `--o-gap`. Arcs follow the ring's `--o-range`, `--o-from` and `.ccw` (see [Orbits](https://zumerlab.com/orbit/reference/orbit.html#angles)). `.angle-N` on an arc starts it at that angle instead of its turn on the ring. ## Curved text Text inside an `` is drawn along it. Style it like any text: `color`, `font-size`, `font-weight`, `text-transform`. The font size is the same in px on every ring. Set `--o-fill: transparent` for text with no band. `text-anchor` places the text at the `start`, `middle` (default) or `end` of its arc. ```html
start middle end
No band, in bold
``` ```css o-arc { --o-fill: color-mix(in srgb, var(--accent) 18%, transparent); --o-stroke: none; --o-gap: 4; color: var(--text); font-size: 14px; } o-arc.plain { --o-fill: transparent; color: var(--accent); font-weight: 700; } ``` Text runs clockwise, so on the lower half of a ring it reads upside down. `flip` runs it the other way: ```html
``` ```css o-arc { --o-fill: color-mix(in srgb, var(--accent) 18%, transparent); --o-stroke: none; --o-gap: 3; color: var(--text); font-size: 13px; } o-arc[flip] { --o-fill: color-mix(in srgb, var(--amber) 20%, transparent); } ``` ```js const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; // April to September sit on the lower half: flip them so they read upright. document.getElementById('months').innerHTML = months.map((month, i) => `= 3 && i <= 8 ? ' flip' : ''}>${month}
`).join(''); ``` April to September, in amber, have `flip`. Text longer than its arc is cut off, and the console says so with an `[orbit]` message. Shorten it, widen the range, let the arc fit the text with `range="auto"`, or add `fit-range` to squeeze or stretch the text to the arc: ```html
This text runs along the whole arc
270°
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } o-arc { --o-fill: color-mix(in srgb, var(--accent) 18%, transparent); --o-stroke: none; color: var(--text); font-size: 14px; } ``` ```js const range = document.getElementById('range'); range.addEventListener('input', () => { document.getElementById('ring').style.setProperty('--o-range', range.value + 'deg'); document.getElementById('out').value = range.value + '°'; }); ``` Change the range: the text stretches or squeezes to fill it. ## Arcs that fit their text With `range="auto"` an arc is as long as its text, with half a letter of room at each end. Consecutive auto arcs follow each other, which makes curved menus easy: ```html
Homeyou are here
``` ```css o-arc { --o-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; --o-gap: 4; color: var(--text); font-size: 14px; cursor: pointer; } o-arc:hover, o-arc:focus-visible { --o-fill: color-mix(in srgb, var(--accent) 32%, transparent); outline: none; } .menu { border-color: var(--line); } o-arc[aria-current] { --o-fill: var(--accent); color: var(--bg); font-weight: 600; } .readout strong { font-size: 30px; } ``` ```js const items = document.querySelectorAll('.menu o-arc'); const pick = item => { items.forEach(other => other.toggleAttribute('aria-current', other === item)); document.getElementById('page').textContent = item.textContent; }; items.forEach(item => { item.addEventListener('click', () => pick(item)); item.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pick(item); } }); }); ``` Three auto arcs, one after another. Click one, or tab to it and press Enter. An auto arc starts at its angle. `text-anchor="middle"` centers it there instead, and `text-anchor="end"` ends it there. Both labels here are anchored at the top: ```html
Centered at 0°
Ends at 0°
``` ```css .lane { border: 1px dashed var(--line); } o-arc { --o-fill: color-mix(in srgb, var(--accent) 18%, transparent); --o-stroke: none; color: var(--text); font-size: 14px; } .mark { height: 2px; background: var(--amber); } ``` The amber line points at 0°. ## Pointer interaction Arcs are decorative by default, so they never steal clicks from controls nearby. `interactive` makes the whole band clickable, even where its fill is transparent, and `hit-width` adds touch area in px on each side, which helps with thin arcs on touch screens. ```html
``` ```css o-arc { --o-fill: transparent; --o-stroke: var(--accent); --o-stroke-width: 2; cursor: pointer; } o-arc:focus-visible { outline: none; --o-stroke-width: 3; } o-arc[aria-checked="true"] { --o-stroke: var(--amber); } ``` ```js const arc = document.querySelector('o-arc'); const toggle = () => arc.setAttribute('aria-checked', String(arc.getAttribute('aria-checked') !== 'true')); arc.addEventListener('click', toggle); arc.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggle(); } }); ``` Only the outline is drawn, yet a click anywhere on the band, or up to 12px around it, toggles the color. When an arc acts as a control, you bring the keyboard behavior and the accessible name. Arcs are placed with the `rotate` property, so your own `transform` or `scale` (on hover, say) adds to it instead of replacing it. ## After drawing Each `` fires a bubbling `o-rendered` event after it redraws. To wait for every arc on the page, for example before measuring, use `await Orbit.ready()`. See [Runtime and resizing](https://zumerlab.com/orbit/craft/runtime.html). --- Source: https://zumerlab.com/orbit/reference/progress.html # Progress `` fills a lane up to a value, over a track that shows the whole range. It is the base of progress rings, gauges and meters. ```html
0%uploaded
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; } .readout strong { font-size: 40px; } ``` ```js const bar = document.getElementById('bar'), input = document.getElementById('input'); const set = value => { bar.setAttribute('value', value); bar.setAttribute('aria-valuenow', value); document.getElementById('value').textContent = value + '%'; }; input.addEventListener('input', () => set(input.value)); // Fill up on load. const target = Number(input.value), start = performance.now(); const grow = now => { const t = Math.min(1, (now - start) / 900); set(Math.round(target * (1 - (1 - t) ** 3))); if (t < 1) requestAnimationFrame(grow); }; requestAnimationFrame(grow); ``` A progress goes straight into a ring, one per ring: put each bar of a multi-bar meter on its own ring. It is drawn by the runtime, so it needs the Orbit script. For screen readers, give it `role="progressbar"` and the `aria-` values, as above. ## Reference | Attribute | Default | What it does | | `value` | `0` | Progress, out of `max`. | | `max` | `100` | The value that fills the ring's range. | | `shape` | `none` | The ending. See Endings. | | `variant` | | `stroke` draws thin lines instead of filled bands. See Stroke variant. | | `interactive` | | Makes the whole track clickable, whatever its colors. See Pointer interaction. | | `hit-width` | `0` | Extra touch area, in px, on each side of an `interactive` track. | | Property or class | Default | What it does | | `--o-progress` | | The value, set from CSS. Wins over `value`. | | `--o-fill` | `var(--o-gray-light)` | Bar color. Not used by `variant="stroke"`. | | `--o-stroke` | `var(--o-fill)` | Bar outline, or the line color with `variant="stroke"`. | | `--o-stroke-width` | `1` (`2` with `stroke`) | Bar outline width, or the line width, in px. | | `--o-back-fill` | `transparent` | Track color. Not used by `variant="stroke"`. | | `--o-back-stroke` | `none` (gray with `stroke`) | Track outline, or the track line color. | | `--o-back-stroke-width` | `1` | Track outline or line width, in px. | | `--o-size-ratio` | `1` | Thickness, in lanes. `.grow-*` and `.shrink-*` work as on [satellites](https://zumerlab.com/orbit/reference/satellite.html). | | `.inner-orbit`, `.outer-orbit`, `.quarter-*-orbit` | | Moves the band in or out from its lane. | | `.angle-0` … `.angle-360` | | Starts the bar at that angle instead of the ring's `--o-from`. | ## Value and color `value` runs from 0 to `max` (100 by default) and fills that share of the ring's range. `--o-fill` colors the bar and `--o-back-fill` the track behind it; `--o-stroke` and `--o-back-stroke` outline them. `--o-progress` sets the value from CSS and wins over the attribute. Use it when a class or a parent owns the state: ```html
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } o-progress { --o-stroke: none; } o-progress.level { --o-progress: 30; --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); } .full o-progress.level { --o-progress: 90; } o-progress.other { --o-fill: var(--amber); --o-back-stroke: var(--faint); } ``` ```js const toggle = document.getElementById('toggle'); toggle.addEventListener('click', () => { const full = document.querySelector('.bigbang').classList.toggle('full'); toggle.setAttribute('aria-pressed', full); }); ``` Outer ring: `--o-progress` from a class. Inner ring: `value="50" max="80"`, with an outlined track. Orbit redraws when the element's or an ancestor's `class` or `style` changes. After a change it cannot see, such as editing a stylesheet rule, call `Orbit.refresh()`. The value does not animate: a `transition` on `--o-progress` jumps straight to the new value. ## Gauges A progress fills the ring's range, so a partial range makes a gauge. `range-240 from-240` leaves the gap at the bottom: ```html
64km/h
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } o-progress { --o-fill: var(--accent); --o-back-fill: var(--line); --o-stroke: none; } .readout strong { font-size: 36px; } ``` ```js const input = document.getElementById('input'); input.addEventListener('input', () => { document.getElementById('bar').setAttribute('value', input.value); document.querySelector('.bigbang').setAttribute('aria-valuenow', input.value); document.getElementById('value').textContent = input.value; }); ``` ## Endings `shape` sets how the bar ends: `circle` (or `circle-a`), `circle-b`, `bullet`, `arrow`, `slash`, `backslash`, `rounded` and `zigzag`. Endings are sized from the band, so they look the same on every ring and at every size. ```html
``` ```css .grid { display: grid; grid-template-columns: repeat(auto-fit, 92px); justify-content: center; justify-items: center; width: min(100%, 560px); gap: 14px 24px; } .cell { display: grid; justify-items: center; gap: 4px; } .cell code { color: var(--muted); } .bigbang { width: 92px; } .gravity-spot { --o-size-ratio: 3; } o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 18%, transparent); --o-stroke: none; } ``` ```js const shapes = ['none', 'circle', 'circle-b', 'bullet', 'arrow', 'slash', 'backslash', 'rounded', 'zigzag']; document.getElementById('grid').innerHTML = shapes.map(shape => `
${shape}
`).join(''); ``` ## Stroke variant By default the bar is a filled band. For a thin gauge line, add `variant="stroke"`: the bar and the track become lines along the middle of the lane, with round caps. - `--o-stroke` colors the line and `--o-back-stroke` the track. `--o-fill` and `--o-back-fill` are not used. - The line is 2px wide (`--o-stroke-width`) and the track 1px (`--o-back-stroke-width`), in screen pixels at any size. - The track is gray by default; set `--o-back-stroke` to color it, or `none` to hide it. - `shape` does not apply. A full value on a 360° range draws a closed circle. ```html
``` ```css o-progress { --o-stroke: var(--accent); } o-progress.thick { --o-stroke-width: 8; --o-back-stroke: var(--line); --o-back-stroke-width: 4; } o-progress.tinted { --o-stroke: var(--amber); --o-back-stroke: color-mix(in srgb, var(--amber) 30%, transparent); } o-progress.full { --o-stroke: var(--muted); } o-progress.gauge { --o-stroke-width: 4; --o-back-stroke: var(--line); } ``` From the outside in: an 8px line, a tinted track, a full circle, and a 270° gauge. ## Thickness and position A progress is one lane thick. Set `--o-size-ratio` in lanes on the element, its ring or the `.gravity-spot`, or use the `.grow-*` and `.shrink-*` classes on the element. `.inner-orbit` and `.outer-orbit` move the band in or out so one of its edges sits on the lane, and the `.quarter-` versions go part of the way. A band two lanes thick on `orbit-11` ends exactly at the edge of the universe. ```html
2
``` ```css .stage { gap: 12px; align-content: center; } .stage > .bigbang { width: min(100%, 100vh - 84px); } o-progress { --o-stroke: none; --o-fill: var(--c); --o-back-fill: color-mix(in srgb, var(--c) 16%, transparent); } .move { --c: var(--coral); } .exercise { --c: var(--lime); } .stand { --c: var(--accent); } ``` ```js const size = document.getElementById('size'); size.addEventListener('input', () => { document.getElementById('system').style.setProperty('--o-size-ratio', size.value); document.getElementById('out').value = size.value; }); ``` Three rings, their thickness set once on the `.gravity-spot`. At 2 lanes they touch each other and the edge. ## Pointer interaction `` is decorative by default, so it never steals clicks from controls nearby. `interactive` makes the whole track clickable, including the part the bar does not cover, whatever its colors. `hit-width` adds touch area in px on each side, which helps with thin bars on touch screens. ```html
30click the ring
``` ```css o-progress { --o-stroke: var(--accent); --o-stroke-width: 4; --o-back-stroke: var(--line); --o-back-stroke-width: 4; cursor: pointer; } o-progress:focus-visible { outline: none; --o-back-stroke: var(--faint); } .readout strong { font-size: 30px; } ``` ```js const bar = document.getElementById('bar'); const set = value => { bar.setAttribute('value', value); bar.setAttribute('aria-valuenow', value); document.getElementById('value').textContent = value; }; // A click adds 10 and wraps after 100; the arrow keys step by 10 too. bar.addEventListener('click', () => set((Number(bar.getAttribute('value')) + 10) % 110)); bar.addEventListener('keydown', event => { const step = { ArrowUp: 10, ArrowRight: 10, ArrowDown: -10, ArrowLeft: -10 }[event.key]; if (step) { event.preventDefault(); set(Math.max(0, Math.min(100, Number(bar.getAttribute('value')) + step))); } }); ``` Each click on the thin track, or up to 12px around it, adds 10. When a progress acts as a control, you bring the keyboard behavior and the accessible name. It is placed with the `rotate` property, so your own `transform` or `scale` adds to it instead of replacing it. ## After drawing Each `` fires a bubbling `o-rendered` event after it redraws. To wait for everything on the page, for example before measuring, use `await Orbit.ready()`. See [Runtime and resizing](https://zumerlab.com/orbit/craft/runtime.html). --- Source: https://zumerlab.com/orbit/craft/depth.html # Depth layers Add `depth` to a `.gravity-spot` and its rings stack on the z axis. The layer in front is the one people read and use; the others wait behind it. Each direct child of the `.depth` element is a layer: a ring, or a nested `.gravity-spot` when a layer needs several rings of its own. - `data-depth="1"` moves a layer one step back. Values go from `0` to `8`. - `.layer-front` marks the active layer and places it at depth 0. - `--o-z` sets the position directly, in unitless pixels. Negative values are farther away. Layers keep their apparent size: the scale makes up for the perspective, `(perspective − z) / perspective`. So three rings on `orbit-6` would sit exactly on top of each other. `--o-depth-grow` makes each step back a bit bigger, so you can see them all. Depth changes animate. ```html
``` ```css body { display: grid; grid-template-rows: minmax(0, 1fr) auto; height: 100vh; padding: 16px 16px 14px; } .bigbang { max-height: 100%; } .controls { justify-content: center; } .gravity-spot { --o-size-ratio: 1.2; --o-depth-grow: .42; --o-back-opacity: .5; } o-progress { --o-stroke: none; --o-fill: var(--faint); --o-back-fill: var(--line); } .layer-front o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 16%, transparent); } ``` ```js const layers = [...document.querySelectorAll('.depth > [data-depth]')]; let front = 0; document.querySelector('#next').addEventListener('click', () => { front = (front + 1) % layers.length; layers.forEach((layer, i) => { const depth = (i - front + layers.length) % layers.length; layer.dataset.depth = depth; layer.classList.toggle('layer-front', depth === 0); }); }); ``` ## A whole system as a layer A layer can hold several rings, a label and a face that move together. Make it a nested `.gravity-spot` with `data-depth`. It has the same reach as its parent, so it needs no `--o-force`, and it does not block the pointer outside its satellites. Below, three gauges are stacked that way. Click a face to bring it forward, and move the pointer over the frame to tilt the stack. ```html
``` ```css body { height: 100vh; display: grid; place-items: center; } /* The ancestor that owns the perspective. */ .tilt { width: min(90vw, 260px); perspective: 1000px; } .tilt > .bigbang { rotate: x var(--rx, 0deg); transform: rotateY(var(--ry, 0deg)); transition: rotate .2s, transform .2s; } /* Same value as the perspective above, without the unit. */ .stack { --o-perspective: 1000; --o-depth-grow: .24; --o-back-opacity: .3; } .layer o-progress { --o-stroke: none; --o-fill: var(--c); --o-back-fill: color-mix(in srgb, var(--c) 14%, var(--bg)); } .face { width: 100%; aspect-ratio: 1; border-radius: 50%; display: grid; place-content: center; text-align: center; background: color-mix(in srgb, var(--c) 10%, var(--panel)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c) 40%, transparent); border: 0; padding: 0; color: var(--text); cursor: pointer; } .face strong { color: var(--c); font-size: 30px; font-variant-numeric: tabular-nums; } .face span { font-size: 13px; color: var(--muted); } ``` ```js const layers = [...document.querySelectorAll('.stack > .layer')]; let front = 0; const bring = (index) => { front = (index + layers.length) % layers.length; layers.forEach((layer, i) => { const depth = (i - front + layers.length) % layers.length; layer.dataset.depth = depth; layer.classList.toggle('layer-front', depth === 0); layer.style.zIndex = layers.length - depth; }); }; layers.forEach((layer, i) => { layer.querySelector('.face').addEventListener('click', () => bring(i === front ? i + 1 : i)); }); bring(0); const tilt = document.querySelector('.tilt'); document.addEventListener('pointermove', (e) => { tilt.style.setProperty('--ry', (e.clientX / innerWidth - .5) * 30 + 'deg'); tilt.style.setProperty('--rx', -(e.clientY / innerHeight - .5) * 30 + 'deg'); }); document.addEventListener('pointerleave', () => { tilt.style.setProperty('--rx', '0deg'); tilt.style.setProperty('--ry', '0deg'); }); ``` ## Perspective from an ancestor By default the `.depth` element owns the perspective. To tilt the whole object, as above, an ancestor has to own it instead: add `outer-perspective` next to `depth`, give the ancestor a `perspective`, and set `--o-perspective` to the same number so layers still keep their apparent size. Elements between that ancestor and `.depth` need `transform-style: preserve-3d`; a `.bigbang` that holds the `.depth` element gets it automatically. ```html
…
…
``` ## Properties Set these on the `.depth` element, or on a single layer. | Property | Default | What it does | | `--o-z` | `depth × step` | Position on the z axis in unitless pixels. Set it on a layer to override `data-depth`. | | `--o-depth-step` | `-110` | Distance between depth steps. | | `--o-perspective` | `900` | Perspective in unitless pixels. | | `--o-depth-grow` | `0` | Extra size per step back. `.42` gives 1×, 1.42×, 1.84×, so no two layers share a radius. | | `--o-depth-compensate` | `1` | `0` lets farther layers look smaller. | | `--o-back-opacity` | `1` | Opacity of the layers behind the front one. | | `--o-depth-duration` | `.8s` | Duration of the depth transition. | | `--o-depth-easing` | `cubic-bezier(.2,.8,.2,1)` | Easing of the depth transition. | The transition is off when the user prefers reduced motion. ## Rules - **Layers are direct children of `.depth`.** An element in between flattens the 3D context. - **Rings outside `.depth` are not transformed.** `data-depth` alone does nothing. - **Satellites, vectors, sides, `` and `` are never layers.** They keep their own transforms; put them in a ring and move the ring. - **Back layers still receive clicks.** Rings ignore the pointer and satellites accept it, so a click on a satellite of a back layer reaches it. That is how the faces above come forward. The [depth demo](https://zumerlab.com/orbit/demos/11-depth-layers.html) has the gauges above at full size, and [layered status rings](https://zumerlab.com/orbit/examples/layered-status-rings.html) a fuller example with alerts. --- Source: https://zumerlab.com/orbit/craft/themes.html # Themes Orbit ships a plain default look, a cyan theme and a helper for building. All of them are written with `:where()`, so they have zero specificity apart from their own class: a single class of yours wins, without `!important`. ## Default theme Always on. Satellites have no border or background, arcs and progress bands are light gray, and vectors and sides take the current text color. There are no hover colors. ```html
A
B
C
D
``` ## Cyan theme Add `theme-cyan` to the `.bigbang`, or to any ancestor. It outlines satellites, colors everything cyan and adds hover colors to arcs and progress bands. ```html
A
B
C
D
``` ## dev-orbit Add `dev-orbit` while you build. It draws the gravity spot, rings and satellites with dashed red lines, and outlines broken structure. It goes on top of a theme on the same element. See [Visual aids](https://zumerlab.com/orbit/craft/visual-aids.html). ```html
A
B
C
D
``` ## Your own look Style Orbit like anything else: satellites and capsules with ordinary CSS, arcs and progress with custom properties. The [color palette](https://zumerlab.com/orbit/craft/colors.html) is there if you want it. Here is the same layout once more, with a few rules of its own and a hover color on the satellites. ```html
A
B
C
D
``` ```css /* Your own look: ordinary CSS and Orbit's custom properties. */ .orbit-5 .satellite { border-radius: 50%; background: var(--panel); box-shadow: inset 0 0 0 1px var(--line); color: var(--text); font-size: 13px; } .orbit-5 .satellite:hover { box-shadow: inset 0 0 0 1.5px var(--accent); color: var(--accent); } o-arc { --o-fill: var(--line); --o-stroke: none; } o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 12%, transparent); --o-stroke: none; } .vector { background: var(--faint); } .side { background: var(--line); } ``` --- Source: https://zumerlab.com/orbit/craft/colors.html # Colors Orbit's stylesheet defines a palette of custom properties: ten hues, each in seven shades. The themes use it, and you can too, or ignore it and use your own colors. The hues are `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `indigo`, `purple`, `pink` and `gray`. Each one comes as `--o-{hue}` plus six shades mixed from it in OKLab. For cyan: | Property | Mix | | `--o-cyan-white` | 95% white | | `--o-cyan-lighter` | 75% white | | `--o-cyan-light` | 30% white | | `--o-cyan` | the hue itself | | `--o-cyan-dark` | 20% black | | `--o-cyan-darker` | 40% black | | `--o-cyan-black` | 78% black | Below, every shade on an arc: one ring per shade, from `-black` in the middle to `-white` outside, and one arc per hue. A few lines of JavaScript write the seventy arcs. ```html
``` ```css o-arc { --o-stroke: none; } .name { font: 12px var(--mono); color: var(--muted); } ``` ```js const hues = ['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'gray']; const shades = ['-black', '-darker', '-dark', '', '-light', '-lighter', '-white']; const wheel = document.getElementById('wheel'); shades.forEach((shade, i) => { const arcs = hues.map((hue) => ``).join(''); wheel.insertAdjacentHTML('afterbegin', `
${arcs}
`); }); document.getElementById('names').innerHTML = hues.map((hue) => `
${hue}
`).join(''); ``` ## Your own color `--o-color` is the eleventh hue, and it is yours. Set it on a system, a ring or a satellite, and `--o-color-white` to `--o-color-black` are mixed from it right there. Each ring below sets its own `--o-color` and the arcs use the same three shades: `-lighter`, `-light` and `-dark`. ```html
``` ```css .gravity-spot { --o-size-ratio: 2.4; } o-arc { --o-gap: 3; --o-stroke: none; } o-arc:nth-child(1) { --o-fill: var(--o-color-lighter); } o-arc:nth-child(2) { --o-fill: var(--o-color-light); } o-arc:nth-child(3) { --o-fill: var(--o-color-dark); } ``` Arcs and progress take the shades of their ring. On an `` itself, `--o-color` is the color of its text. To change one shade only, set it on the element that uses it. --- Source: https://zumerlab.com/orbit/craft/visual-aids.html # Visual aids Add `dev-orbit` to a `.bigbang` while you build. It draws the gravity spot, the rings and the satellites with dashed red lines, outlines anything in the wrong place, and explains it in the console. It changes how things look, never where they are, so you can switch it on and off freely. It combines with a theme on the same element. [Themes](https://zumerlab.com/orbit/craft/themes.html) shows a full layout with it on. ## Wrong children of a gravity spot A `.gravity-spot` holds rings (`.orbit`, `.orbit-N`) and nested gravity spots, nothing else. If it holds anything else, the whole system is dimmed and a warning sign pulses at its center. Here a satellite sits directly in the gravity spot: ```html
A
B
C
``` ```css .orbit-8 { --o-size-ratio: 2; } ``` ## Wrong children of a satellite or a ring A `.satellite` holds a `.capsule` or a nested `.gravity-spot`. With anything else in it, it gets an orange dotted border and the console names the element. A ring nested in another ring is outlined the same way. This example logs on purpose: ``` [orbit]
sits directly in a .satellite. Put content in a .capsule so it stays upright and centered. ``` ```html
A
C
``` ```css .orbit-8 { --o-size-ratio: 2; } ``` Content placed directly in a ring, instead of in a `.satellite`, is not outlined, but the console reports it. The full list of messages and their fixes is on the [install page](https://zumerlab.com/orbit/install.html#console). The outlines use `:has()`. In a browser without it you still get the console messages. The pulsing sign stops when the user prefers reduced motion. ## Arcs on ellipses `` and `` are drawn on circles only. On a ring stretched into an ellipse with `--o-ellipse-x` or `--o-ellipse-y`, they are hidden, and the console says so. Satellites and vectors follow the ellipse; sides do not. See [Orbits](https://zumerlab.com/orbit/reference/orbit.html#ellipses). --- Source: https://zumerlab.com/orbit/craft/runtime.html # Runtime and resizing Most of the time there is nothing to call. Layouts follow their box, and the runtime notices changes on its own. The few functions it exports are for waiting, forcing an update, and old fixed-size layouts. ## Sizing needs no code Every ring, satellite and band is a fraction of its `.bigbang`, so when the box changes size everything inside changes with it. There is no breakpoint to write. Drag the corner of the dashed box: ```html
``` ```css /* Drag the corner of the box. */ .wrapper { width: 280px; height: 200px; margin: 16px; border: 1px dashed var(--faint); border-radius: 10px; resize: both; overflow: hidden; } .reach { border: 1px dashed var(--accent); } .orbit-8 { --o-size-ratio: 2; } .satellite { border-radius: 50%; background: var(--accent); } ``` The `.bigbang` is square and as wide as its parent, and a parent with a height keeps it from growing taller. Here the box is 280 × 200, so the universe is 200px across. Give the `.bigbang` a `width`, `height` or `aspect-ratio` of its own and the system follows that instead. ## What the runtime watches Orbit watches the page for added, removed and reordered elements, attribute and style changes, and containers that change size. It batches what it sees into the next animation frame. That is why a framework can re-render a layout, or you can change `value` on an ``, and the drawing follows with no extra call. ## Waiting for the drawing `Orbit.ready()` returns a promise that resolves when every pending layout is drawn. Pass an element, a document or a shadow root to wait only for that part. Use it in tests, or before you measure something. Each `` and `` also fires an `o-rendered` event after it redraws. It bubbles and crosses shadow roots, so one listener on a container hears all of its arcs. ```html
40%waiting
``` ```css body { display: grid; grid-template-rows: minmax(0, 1fr) auto; height: 100vh; padding: 16px 16px 14px; } .bigbang { max-height: 100%; } .controls { justify-content: center; } o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; } .readout strong { font-size: 34px; } ``` ```js const progress = document.querySelector('o-progress'); let draws = 0; // Fires after every redraw of the arc. progress.addEventListener('o-rendered', () => { draws++; document.getElementById('value').textContent = progress.getAttribute('value') + '%'; document.getElementById('draws').textContent = `drawn ${draws}×`; }); document.querySelector('input').addEventListener('input', (e) => { progress.setAttribute('value', e.target.value); progress.setAttribute('aria-valuenow', e.target.value); }); Orbit.ready().then(() => console.log('ready')); ``` ## Updating by hand Two kinds of change are invisible to the runtime: rules you edit through CSSOM, and CSS that depends on something outside the layout, like a class on `` or a checkbox elsewhere on the page. After one of those, call `Orbit.refresh()` with the part that changed. Without an argument it refreshes the whole document. ```js sheet.cssRules[0].style.setProperty('--o-range', '180deg'); Orbit.refresh(panel); ``` For shadow DOM, include the stylesheet inside each shadow root. Arcs and progress register their shadow root on their own; for a shadow tree with only CSS elements, call `Orbit.refresh(shadowRoot)` once. ## Fixed sizes and Orbit.resize() When a system should keep a fixed size, give its `.gravity-spot` a length: `--o-force: 300px`. That system no longer follows its box. `Orbit.resize()` brings part of that back: it watches a container and scales the fixed-size systems inside it with its **width**, taking 500px as 1×. ```js const stopResizing = Orbit.resize('.wrapper'); // When the container goes away: stopResizing(); ``` It takes a selector or an element and returns a function that stops watching. Calling it again on the same container replaces the previous watcher. It leaves systems without a fixed reach alone, and if it finds none the console says `[orbit] Orbit.resize() has nothing to do here`. Here the same layout has `--o-force: 300px` and `Orbit.resize('.wrapper')`. At 280px wide the reach is 168px (300 × 280 / 500). Drag the corner: only the width counts. ```html
``` ```css /* Drag the corner of the box. */ .wrapper { width: 280px; height: 200px; margin: 16px; border: 1px dashed var(--faint); border-radius: 10px; resize: both; overflow: hidden; } .reach { border: 1px dashed var(--accent); } .orbit-8 { --o-size-ratio: 2; } .satellite { border-radius: 50%; background: var(--accent); } ``` ```js const stopResizing = Orbit.resize('.wrapper'); ``` For new layouts, leave `--o-force` alone and size the `.bigbang` instead. `Orbit.resize()` is there mostly for layouts written for Orbit 1.x, where every system was 500px across. [Coming from 1.x](https://zumerlab.com/orbit/migrating.html) has the rest. --- Source: https://zumerlab.com/orbit/frameworks/react.html # React Orbit is HTML and CSS with a runtime that watches the page, so in React you write the same markup with `className`. There is no wrapper component to install. ## Install ```sh npm install @zumer/orbit ``` Import the stylesheet and the runtime once, in your entry file or in the component that uses Orbit: ```js import '@zumer/orbit/style'; import '@zumer/orbit'; ``` ## A gauge A progress ring with its value in the middle, bound to state. `value` on `` is an attribute, and React sets it like any other. ```jsx import { useState } from 'react'; import '@zumer/orbit/style'; import '@zumer/orbit'; import './gauge.css'; export default function Gauge() { const [value, setValue] = useState(72); return (
{value}%
setValue(Number(e.target.value))} />
); } ``` ```css /* gauge.css */ .gauge { width: 220px; } .gauge .orbit-10 { --o-size-ratio: 2; } .gauge .orbit-0 { --o-size-ratio: 12; } .gauge o-progress { --o-fill: var(--o-cyan); --o-back-fill: var(--o-cyan-lighter); --o-stroke: none; } .gauge .capsule { font-size: 28px; font-weight: 600; } ``` Sizes go in CSS rather than in `style`, so TypeScript does not complain about custom properties. Lists work as you expect: render satellites with `map()` and Orbit spreads however many there are. ```html
72%
``` ```css body { height: 100vh; display: grid; place-items: center; } .gauge { width: 220px; display: grid; gap: 10px; } .gauge .orbit-10 { --o-size-ratio: 2; } .gauge .orbit-0 { --o-size-ratio: 12; } .gauge o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; } .gauge .capsule { font-size: 34px; font-weight: 650; letter-spacing: -.03em; font-variant-numeric: tabular-nums; } ``` ```js const progress = document.querySelector('o-progress'); const label = document.querySelector('.capsule'); document.querySelector('input').addEventListener('input', (e) => { progress.setAttribute('value', e.target.value); label.textContent = e.target.value + '%'; }); ``` The same markup in plain HTML. Move the slider: changing `value` is all it takes. Orbit watches the page for added, removed and reordered elements, attribute and style changes, and containers that change size, so a re-render needs no extra call. To wait until arcs are drawn, or to update after a change Orbit cannot see, use the [runtime API](https://zumerlab.com/orbit/craft/runtime.html). ## TypeScript The package ships JSX types for `` and ``. Load them once, for example in a `.d.ts` file of your app: ```ts import type {} from '@zumer/orbit/react'; ``` Then `` is checked like any other element, attributes included. ## Server rendering Importing Orbit on the server is safe; it does nothing there. It has to run in the browser too, to register the elements and watch the page. In the Next.js App Router, import the stylesheet in `app/layout.tsx` and the runtime in a component marked `'use client'`. --- Source: https://zumerlab.com/orbit/frameworks/vue.html # Vue In Vue you write Orbit's markup straight into templates. The only setup is telling the compiler that `` and `` are custom elements, not Vue components. ## Install ```sh npm install @zumer/orbit ``` Import the stylesheet and the runtime in your entry file: ```js // main.js import '@zumer/orbit/style'; import '@zumer/orbit'; import { createApp } from 'vue'; import App from './App.vue'; createApp(App).mount('#app'); ``` Then, in the Vite config: ```js // vite.config.js import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [ vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('o-'), }, }, }), ], }); ``` ## A gauge A progress ring with its value in the middle, bound to a ref: ```vue ``` ```css /* gauge.css */ .gauge { width: 220px; } .gauge .orbit-10 { --o-size-ratio: 2; } .gauge .orbit-0 { --o-size-ratio: 12; } .gauge o-progress { --o-fill: var(--o-cyan); --o-back-fill: var(--o-cyan-lighter); --o-stroke: none; } .gauge .capsule { font-size: 28px; font-weight: 600; } ``` Satellites rendered with `v-for` are spread around their ring however many there are. ```html
72%
``` ```css body { height: 100vh; display: grid; place-items: center; } .gauge { width: 220px; display: grid; gap: 10px; } .gauge .orbit-10 { --o-size-ratio: 2; } .gauge .orbit-0 { --o-size-ratio: 12; } .gauge o-progress { --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); --o-stroke: none; } .gauge .capsule { font-size: 34px; font-weight: 650; letter-spacing: -.03em; font-variant-numeric: tabular-nums; } ``` ```js const progress = document.querySelector('o-progress'); const label = document.querySelector('.capsule'); document.querySelector('input').addEventListener('input', (e) => { progress.setAttribute('value', e.target.value); label.textContent = e.target.value + '%'; }); ``` The same markup in plain HTML. Move the slider: changing `value` is all it takes. Orbit watches the page for added, removed and reordered elements, attribute and style changes, and containers that change size, so a re-render needs no extra call. To wait until arcs are drawn, or to update after a change Orbit cannot see, use the [runtime API](https://zumerlab.com/orbit/craft/runtime.html). ## Nuxt Importing Orbit on the server is safe. Put the stylesheet in `css`, the custom element rule in `vue.compilerOptions`, and the runtime in a client plugin: ```js // nuxt.config.ts export default defineNuxtConfig({ css: ['@zumer/orbit/style'], vue: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('o-') }, }, }); // app/plugins/orbit.client.ts import '@zumer/orbit'; export default defineNuxtPlugin(() => {}); ``` In Nuxt 3 the plugin is `plugins/orbit.client.ts`, at the root. Nuxt 4 ignores that folder, so there the elements would never be defined. ## Without a build step With Vue from a CDN, load Orbit's two files with `` and `
{value}%
``` ```css /* gauge.css */ .gauge { width: 220px; } .gauge .orbit-10 { --o-size-ratio: 2; } .gauge .orbit-0 { --o-size-ratio: 12; } .gauge o-progress { --o-fill: var(--o-cyan); --o-back-fill: var(--o-cyan-lighter); --o-stroke: none; } .gauge .capsule { font-size: 28px; font-weight: 600; } ``` In Svelte 4, write `let value = 72` instead of `$state(72)`. The CSS can also live in the component's ` ``` To load Orbit from a CDN instead, put the `` and ` ``` The entry registers every component. To load only some, import their files: `import '@zumer/orbit-kit/o-gauge.js'`. Orbit 2 is a peer dependency; the kit does not work with Orbit 1.x. ## Components - [Gauge](https://zumerlab.com/orbit/kit/gauge.html): One value on an arc. - [Donut](https://zumerlab.com/orbit/kit/donut.html): A donut chart from an array. - [Rings](https://zumerlab.com/orbit/kit/rings.html): Concentric activity rings. - [Knob](https://zumerlab.com/orbit/kit/knob.html): A rotary control. - [Menu](https://zumerlab.com/orbit/kit/menu.html): A radial menu, flat or nested. - [Instrument](https://zumerlab.com/orbit/kit/instrument.html): Needle gauges with presets. - [Clock](https://zumerlab.com/orbit/kit/clock.html): Clocks and watch faces. - [Compass](https://zumerlab.com/orbit/kit/compass.html): A heading indicator. - [Radar](https://zumerlab.com/orbit/kit/radar.html): A scope with a sweep and blips. - [Timer](https://zumerlab.com/orbit/kit/timer.html): A countdown ring with controls. - [Status](https://zumerlab.com/orbit/kit/status.html): Health of many things, in layers. - [Range](https://zumerlab.com/orbit/kit/range.html): Two handles on one ring: a span of values or hours. - [Time picker](https://zumerlab.com/orbit/kit/time-picker.html): Pick a time by turning a clock. - [Spinner](https://zumerlab.com/orbit/kit/spinner.html): Loading rings, dots and orbits. - [Radar chart](https://zumerlab.com/orbit/kit/radar-chart.html): Values across axes, on a web. - [Sunburst](https://zumerlab.com/orbit/kit/sunburst.html): A tree as nested rings, with zoom. - [Wheel](https://zumerlab.com/orbit/kit/wheel.html): A prize wheel that spins to a result. - [Color wheel](https://zumerlab.com/orbit/kit/color-wheel.html): A hue ring color picker. ## What they share Once you know one component, you know most of the others. | Attribute | What it does | | `size` | Width of the widget, as any CSS length. Most are square; `100%` follows the container as it resizes. | | `thickness` | Band thickness in lanes, Orbit's unit: a lane is a twelfth of the radius. | | `fill`, `track` | Value and track colors of the band components: gauge, knob and timer. | | `accent` | The highlight of the dial components: instrument, clock, compass and menu. | | `label`, `unit` | The text in the middle. | | `range`, `from` | Arc span and start angle in degrees. Partial ranges center their gap at the bottom. | | `glow` | A glow in the indicator color; `glow="12"` sets the blur in px. | | `data`, `items`, `targets`, `zones` | Lists, as JSON in the attribute. | | `min`, `max`, `step` | The range of a value, read like ``. | | `format`, `locale` | How numbers are written: percent, currency, units, compact. | | `theme` | `dark`, `light` or `glass`, or an `ok-theme-*` class on a container. | | `name` | The knob and the menu are form controls. | The last four work the same everywhere; [Shared options](https://zumerlab.com/orbit/kit/options.html) has the details and live examples. Change an attribute and the component updates. Values only move what moves; a new structure, like a different number of segments, rebuilds the inside. Colors live in custom properties that inherit, so one rule themes a whole panel: ``` .dashboard { --ok-fill: var(--accent); /* value arcs */ --ok-track: color-mix(in oklab, var(--accent) 22%, #0c0f13); /* tracks */ --ok-accent: #f97316; /* needles, hands, highlights */ --ok-text: #e2e8f0; /* readouts */ --ok-face: #0b1220; /* dials */ } ``` Each page lists the properties of its component. Attributes set the same properties on the element itself, so an attribute wins over a rule on a parent. Text inside a component scales with it: the base font size is a share of the widget (`--ok-font-size`), and readouts are sized in `em`. A 120px gauge and a 400px gauge look the same, only bigger. Every component has a role and a name that follow its value: gauges and instruments are meters, the knob is a slider, the menu is a menu, the timer is a timer. Write your own `aria-label` and the component keeps it. Decorative motion stops when the system asks for reduced motion. ## Coming from 0.x Tag names and attributes from 0.x still work. What changed is underneath: - The kit runs on Orbit 2 and no longer works with 1.x. - Components are sized by their universe. The outer band touches the edge of the widget, so a gauge looks a little bigger in the same `size`. - `thickness` sets band thickness. `orbit` and `stroke-width` still work as before. - `gap` is now a share of the widget width, the same in the donut, the menu and the status ring. - Colors are custom properties you can set from CSS, and text scales with the widget. - New: [``](https://zumerlab.com/orbit/kit/timer.html) and [``](https://zumerlab.com/orbit/kit/status.html), `min` on the gauge, `accent` on the compass and the radar. - `` still ships; [``](https://zumerlab.com/orbit/kit/instrument.html) does all it does and more. --- Source: https://zumerlab.com/orbit/kit/options.html # Shared options Themes, number format, `min`, `max` and `step`, and forms work the same in every kit component that has them. ## Themes Put `theme="dark"`, `"light"` or `"glass"` on a component, or the class `ok-theme-dark`, `ok-theme-light` or `ok-theme-glass` on a container. Everything inside follows, so one class themes a whole dashboard. ```html

Dark

Light

Glass

``` ```css body { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; align-items: center; padding: 16px; } .panel { display: grid; justify-items: center; gap: 10px; padding: 18px 22px 12px; border-radius: 16px; border: 1px solid var(--line); } .panel p { margin: 0; font-size: 13px; color: var(--ok-text); opacity: .7; } .ok-theme-dark { background: var(--panel); } .ok-theme-light { background: #f6f7f9; } .ok-theme-glass { background: linear-gradient(140deg, #6d28d9, #0ea5e9 60%, #14b8a6); } ``` ```js // Each knob drives the gauge in its panel. for (const panel of document.querySelectorAll('.panel')) { const gauge = panel.querySelector('o-gauge'); panel.querySelector('o-knob').addEventListener('input', (e) => gauge.setAttribute('value', e.detail.value)); } ``` Turn a knob: the gauge next to it follows. - A theme is a set of the `--ok-*` custom properties, inside `:where()`. It has zero specificity, so any rule of yours wins, and color attributes like `fill` win too. - Custom properties inherit, so the closest theme wins: a `theme="light"` gauge inside an `ok-theme-dark` panel is light. - Without a theme, components keep their own defaults. - Glass makes faces and panels translucent and blurs what is behind them (`backdrop-filter`). It wants a colorful or photographic background. The properties a theme sets, and what reads them: | Property | What it colors | | `--ok-fill`, `--ok-track` | Value arcs and their tracks. | | `--ok-accent` | Needles, second hands, compass index, menu highlight. | | `--ok-text` | Readouts, numerals and labels. | | `--ok-face`, `--ok-bezel`, `--ok-case` | Dials, their rims and the case around them. | | `--ok-tick`, `--ok-hand` | Minor ticks; hands, major ticks and strong numbers. | | `--ok-surface` | Panels such as the menu hub and the needle cap. | | `--ok-focus` | Focus rings. | | `--ok-backdrop` | The `backdrop-filter` of faces and panels. Set by glass. | To tweak a theme, set a property next to it: ``` .dashboard { --ok-fill: #ffb547; } /* beats the theme */
…
``` The menu already had a `theme` attribute for its sector colors. That is now `palette="mono|color"`; `theme="color"` still works and combines with the others, as in `theme="color light"`. ## Number format `format` picks how numbers are written, through `Intl.NumberFormat`. It reaches the readout, the tick labels and `aria-valuetext`. ```html
``` ```css .stage { gap: 14px; align-content: center; } .controls { justify-content: center; } #formats { display: flex; flex-wrap: wrap; gap: 6px; } button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } select { font: inherit; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; } ``` ```js const dial = document.getElementById('dial'); // Each button is a set of attributes; "plain" removes them all. const formats = { plain: {}, decimal: { format: 'decimal', decimals: '1' }, percent: { format: 'percent' }, currency: { format: 'currency', currency: 'EUR' }, unit: { format: 'unit', unit: 'megabyte' }, compact: { format: 'compact' } }; const buttons = document.getElementById('formats'); for (const name of Object.keys(formats)) { const button = document.createElement('button'); button.textContent = name; button.setAttribute('aria-pressed', name === 'plain'); button.onclick = () => { for (const attr of ['format', 'decimals', 'currency', 'unit']) dial.removeAttribute(attr); for (const [attr, value] of Object.entries(formats[name])) dial.setAttribute(attr, value); for (const other of buttons.children) other.setAttribute('aria-pressed', other === button); }; buttons.append(button); } document.getElementById('locale').onchange = (e) => dial.setAttribute('locale', e.target.value); ``` Switch the format and the locale: the readout and the scale change together. | Attribute | What it does | | `format` | `decimal`, `percent`, `currency`, `unit` or `compact` (1.2K, 3.4M). | | `decimals` | Digits after the decimal point, fixed. Without it, each format uses its usual number. | | `currency` | ISO code for `format="currency"`, `USD` by default. Setting it alone implies the currency format. | | `unit` | Text after the number, as before (`unit="%"`). With `format="unit"` it names an Intl unit instead: `kilometer-per-hour`, `celsius`, `megabyte`. | | `locale` | A language tag like `de-DE`. Without it, the closest `lang` attribute, then the browser's language. | `percent` shows where the value sits between `min` and `max`: 50 on a 0 to 200 gauge reads 25%. In a donut it is the share of the total. Without any of these attributes, numbers look the way they did in 1.0. A bad currency or unit never breaks the component: it falls back to a plain number. ### A formatter for full control Set `formatter` to a function and it writes every number. The second argument says where the number goes: `kind` is `value`, `tick` or `aria`, and you get `min`, `max` and `locale`. Its text is used as is, without the unit. ```html ``` ```css body { display: grid; place-items: center; height: 100vh; } ``` ```js // Minutes as "1h 35m" on screen, and in words for screen readers. document.getElementById('time').formatter = (minutes, { kind }) => { const h = Math.floor(minutes / 60), m = Math.round(minutes % 60); return kind === 'aria' ? `${h} hours ${m} minutes` : `${h}h ${String(m).padStart(2, '0')}m`; }; ``` ## min, max and step Every component with a value reads `min`, `max` and `step` the way `` does. ```html

Screen readers hear

``` ```css .stage { gap: 6px; align-content: center; } .controls { margin: 0; } ``` ```js const trim = document.getElementById('trim'), said = document.getElementById('said'); const show = () => { said.textContent = trim.getAttribute('aria-valuetext'); }; trim.addEventListener('input', show); customElements.whenDefined('o-knob').then(show); ``` Drag, scroll or use the arrow keys: every value lands on a half step. - Values are clamped to the range, readouts included. A `max` below `min` becomes `min`. - `step` snaps values to a grid that starts at `min`. Both ends stay reachable, even when the step does not divide the range. `step="any"` turns snapping off. - Display components (gauge, meter, instrument) do not snap unless you give them a `step`. The knob steps by 1. - Keys: arrows move one step, Page Up and Page Down ten, Home and End go to the ends. Without a step they move 1% of the range. - `aria-valuemin`, `aria-valuemax`, `aria-valuenow` and `aria-valuetext` follow, the last one through the number format. Rings read `min` and `max` as defaults for each ring, and a ring in `data` can set its own. The radar reads `dist` between them, so blips can be in kilometers. On the rings, `step` was already the space between rings; it keeps that meaning, and `spacing` is its new name. ## Forms `` and `` are form controls. Give them a `name` and they submit with their form like an ``. ```html
``` ```css .stage { gap: 14px; align-content: center; } .row { display: flex; gap: 28px; align-items: center; justify-content: center; } .controls { justify-content: center; } ``` ```js const form = document.getElementById('order'), sent = document.getElementById('sent'); form.addEventListener('submit', (e) => { e.preventDefault(); sent.textContent = new URLSearchParams(new FormData(form)).toString(); }); form.addEventListener('reset', () => { sent.textContent = ''; }); ``` Order without a size and the browser points at the menu. Reset puts the knob back on 3. | Name | What it does | | `name` | The field name in the form data. Without it, nothing is sent. | | `value` | The attribute is the default value: the control starts there and a form reset goes back to it. The property is the current value; setting it fires no event. | | `disabled` | Out of the tab order, ignores input and is not sent. A disabled `
` does the same. `:disabled` works in CSS. | | `required` | Menu only: the form does not submit until an item is picked. A knob always has a value. | | `input`, `change` | Events, as on native inputs: `input` on every change by the user, `change` when a gesture ends, on each key press, and on each menu pick. `event.detail.value` has the value. | | `form`, `labels`, `validity`, `checkValidity()`, `reportValidity()` | The same as on an ``. A `
``` ```css .stage { gap: 14px; align-content: center; } .stage > .rating { width: min(100%, 520px, 200vh - 340px); aspect-ratio: 2 / 1; } .zones o-arc { --o-stroke: none; --o-fill: var(--c); opacity: .3; transition: opacity .3s; } .zones o-arc.on { opacity: 1; } .names o-arc { --o-fill: transparent; --o-stroke: none; --o-color: var(--muted); font-size: 13px; } .names o-arc.on { --o-color: var(--text); } .needle-ring { rotate: var(--needle, -90deg); transition: rotate .5s cubic-bezier(.3, 1.4, .5, 1); } .needle { height: 4px; border-radius: 2px; background: var(--text); } .hub { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--panel); border: 2px solid var(--text); } .readout strong { color: var(--c); } ``` ```js const zones = [ ['very poor', 'var(--coral)'], ['poor', 'var(--amber)'], ['fair', 'color-mix(in srgb, var(--amber), var(--lime))'], ['good', 'var(--lime)'], ['excellent', 'var(--accent)'], ]; // Arcs without value split the range evenly: five zones of 36°. document.getElementById('zones').innerHTML = zones.map(([, c]) => ``).join(''); document.getElementById('names').innerHTML = zones.map(([name]) => `${name}`).join(''); const gauge = document.querySelector('.rating'), input = document.getElementById('value'); const arcs = [...document.querySelectorAll('.zones o-arc, .names o-arc')]; const set = value => { const i = Math.min(4, Math.floor(value / 20)), [name, color] = zones[i]; gauge.style.setProperty('--needle', `${value * 1.8 - 90}deg`); gauge.setAttribute('aria-valuenow', value); gauge.setAttribute('aria-valuetext', `${value}, ${name}`); arcs.forEach((arc, j) => arc.classList.toggle('on', j % 5 === i)); document.querySelector('.readout').style.setProperty('--c', color); document.getElementById('score').textContent = value; document.getElementById('zone').textContent = name; }; input.addEventListener('input', () => set(Number(input.value))); set(Number(input.value)); ``` ## How it's built - The [universe](https://zumerlab.com/orbit/reference/bigbang.html) is 2:1 with `.at-bottom`, so the center sits on its bottom edge. `--o-force: 100cqw` makes the reach the width instead of the shorter side. - Zones and names live on two rings that share `range-180 from-270`. Five [``](https://zumerlab.com/orbit/reference/arc.html) without `value` split the half circle evenly, so each name lands in the middle of its zone with no math. - The names are text arcs with a transparent fill: `--o-color` colors the letters, and `font-size` is the same px size on any ring. - The needle is one [`.vector`](https://zumerlab.com/orbit/reference/vector.html) eight lanes long. Its ring turns with `rotate`, from `-90deg` for 0 to `90deg` for 100. - The universe is a `role="meter"`. The script keeps `aria-valuenow`, and an `aria-valuetext` with the zone name, in step with the drawing. For "lower is better" metrics like latency, list the zones the other way round, teal first. ## Related - [240° gauge](https://zumerlab.com/orbit/examples/gauges/240-degree-gauge.html): a speedometer with numbers - [Fuel gauge](https://zumerlab.com/orbit/examples/gauges/fuel-gauge.html): a quarter circle for tight spaces - [Half gauge demo](https://zumerlab.com/orbit/demos/06-semicircle.html): the same universe, in cells - [Orbit Kit gauge](https://zumerlab.com/orbit/kit/gauge.html): a ready-made component --- Source: https://zumerlab.com/orbit/examples/gauges/240-degree-gauge.html # 240° gauge A stepped speedometer: fifteen bands on a 240° arc light up with the speed, each with its number curved over it. ```html
0km/h
``` ```css .stage { gap: 4px; align-content: center; } .stage > .speedo { width: min(100%, (100vh - 80px) / .78); aspect-ratio: 1 / .78; } /* Reach = the width; the center sits half a width from the top. */ .speedo > .gravity-spot { --o-force: 100cqw; margin-top: 50cqw; } .bands o-arc { --o-stroke: none; --o-fill: #1d2630; transition: --o-fill .15s; } .bands o-arc.on { --o-fill: var(--accent); } .bands o-arc.on:nth-child(n + 10) { --o-fill: var(--amber); } .bands o-arc.on:nth-child(n + 13) { --o-fill: var(--coral); } .numbers o-arc { --o-fill: transparent; --o-stroke: none; --o-color: var(--faint); font: 12px var(--mono); } .numbers o-arc.on { --o-color: var(--text); } .readout strong { font-size: clamp(28px, 13cqmin, 56px); } ``` ```js // Fifteen steps of 10 km/h. Arcs without value split the 240° evenly. const steps = Array.from({ length: 15 }, (_, i) => (i + 1) * 10); document.getElementById('bands').innerHTML = steps.map(() => '').join(''); document.getElementById('numbers').innerHTML = steps.map(n => `${n}`).join(''); const speedo = document.querySelector('.speedo'), input = document.getElementById('value'); const bands = [...document.querySelectorAll('#bands o-arc')], numbers = [...document.querySelectorAll('#numbers o-arc')]; const set = speed => { speed = Math.max(0, Math.min(150, Math.round(speed))); input.value = speed; // A band lights up once the speed is past the middle of its step. steps.forEach((top, i) => { bands[i].classList.toggle('on', speed >= top - 5); numbers[i].classList.toggle('on', speed >= top - 5); }); speedo.setAttribute('aria-valuenow', speed); document.getElementById('speed').textContent = speed; }; input.addEventListener('input', () => set(input.value)); // Drive: the speed wanders toward a new target every couple of seconds. let timer, target = 0; document.getElementById('drive').addEventListener('click', event => { const on = event.target.getAttribute('aria-pressed') !== 'true'; event.target.setAttribute('aria-pressed', on); event.target.textContent = on ? 'Stop' : 'Drive'; clearInterval(timer); timer = setInterval(() => { if (on && Math.random() < .03) target = 30 + Math.random() * 115; const speed = Number(input.value); set(speed + Math.sign((on ? target : 0) - speed) * Math.min(2, Math.abs((on ? target : 0) - speed))); if (!on && speed === 0) clearInterval(timer); }, 50); if (on) target = 90; }); set(72); ``` ## How it's built - `--o-range: 240deg; --o-from: 240deg` on the [`.gravity-spot`](https://zumerlab.com/orbit/reference/gravity-spot.html) opens the dial at the bottom, and every ring inherits it. - Fifteen [``](https://zumerlab.com/orbit/reference/arc.html) without `value` split the range into equal steps. `--o-gap: 3` keeps a dark line between them, so each step reads on its own. - The numbers are fifteen more arcs on `orbit-11`, with text and a transparent fill. Same count, same range: each number sits over its band. - Lit bands get a class; `:nth-child(n + 10)` and `(n + 13)` turn the last steps amber and coral. - The universe is shorter than it is wide, since the dial leaves the bottom free. `--o-force: 100cqw` keeps the reach at the full width, and `.at-top` plus a top margin of half a width puts the center where it would be in a square. - The readout is a centered satellite ten lanes wide, and the universe is a `role="meter"`. ## Related - [180° gauge](https://zumerlab.com/orbit/examples/gauges/180-degree-gauge.html): a half circle with a needle - [Fuel gauge](https://zumerlab.com/orbit/examples/gauges/fuel-gauge.html): a 90° instrument - [Speedometer demo](https://zumerlab.com/orbit/demos/04-arrow-gauge.html): a 270° dial with ticks and a needle - [Orbit Kit gauge](https://zumerlab.com/orbit/kit/gauge.html): a ready-made component --- Source: https://zumerlab.com/orbit/examples/gauges/fuel-gauge.html # Fuel gauge A 90° instrument for a corner of a dashboard: E to F, a red reserve and a needle, in a universe cut to the size of the dial. ```html
E
½
F
0 L0 km
``` ```css .stage { gap: 10px; align-content: center; } .stage > .fuel { width: min(100%, 420px, (100vh - 90px) * 4 / 3); aspect-ratio: 4 / 3; } /* Reach: 138% of the width, so the 90° dial spans the box. The margin lifts the center off the edge. */ .fuel > .gravity-spot { --o-force: 138cqw; margin-bottom: 7cqw; } .minor .vector { background: var(--faint); } .major .vector { background: var(--muted); height: 3px; } .reserve { --o-fill: var(--coral); --o-stroke: none; } .mark { font-size: clamp(13px, 5cqw, 20px); font-weight: 650; color: var(--muted); } .pump svg { width: clamp(20px, 8cqw, 34px); fill: none; stroke: var(--muted); stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } .low .pump svg { stroke: var(--coral); animation: blink 1s steps(2) infinite; } @keyframes blink { 50% { opacity: .35; } } .readout strong { font-size: clamp(18px, 7cqw, 30px); } .needle-ring { rotate: var(--needle, -45deg); transition: rotate .4s cubic-bezier(.3, 1.3, .5, 1); } .needle { height: 4px; border-radius: 2px; background: linear-gradient(90deg, transparent 0 8%, var(--coral) 8%); } .hub { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--panel); border: 2px solid var(--coral); } ``` ```js document.getElementById('minor').innerHTML = '
'.repeat(17); document.getElementById('major').innerHTML = '
'.repeat(5); const tank = 48, perLitre = 14; // litres, km per litre const gauge = document.querySelector('.fuel'); let level = 0, timer; const set = value => { level = Math.max(0, Math.min(100, value)); gauge.style.setProperty('--needle', `${level * .9 - 45}deg`); gauge.classList.toggle('low', level < 12.5); gauge.setAttribute('aria-valuenow', Math.round(level)); const litres = level / 100 * tank; document.getElementById('litres').textContent = `${Math.round(litres)} L`; document.getElementById('range').textContent = `${Math.round(litres * perLitre)} km`; }; const run = (step, until) => { clearInterval(timer); timer = setInterval(() => { set(level + step); if (until(level)) clearInterval(timer); }, 60); }; const drive = document.getElementById('drive'); drive.addEventListener('click', () => { const on = drive.getAttribute('aria-pressed') !== 'true'; drive.setAttribute('aria-pressed', on); drive.textContent = on ? 'Park' : 'Drive'; if (on) run(-.15, l => l <= 0); else clearInterval(timer); }); document.getElementById('fill').addEventListener('click', () => { drive.setAttribute('aria-pressed', false); drive.textContent = 'Drive'; run(1.5, l => l >= 100); }); set(30); ``` ## How it's built - `--o-range: 90deg; --o-from: 315deg` on the [`.gravity-spot`](https://zumerlab.com/orbit/reference/gravity-spot.html) centers a quarter circle on the top. Every ring inherits it, so ticks, labels and the reserve line up. - The universe is 4:3 with `.at-bottom`. `--o-force: 138cqw` makes the reach larger than the box, so the 90° of dial that is used fills it, and a bottom margin keeps the hub inside. - `fit-range` puts the first and last tick, and E and F, on the two ends of the range: 17 minor and 5 major [`.vector`](https://zumerlab.com/orbit/reference/vector.html)s. - The reserve is one [``](https://zumerlab.com/orbit/reference/arc.html): a value counts out of the ring's range, so it covers the first eighth of the 90°. - The pump, the readout and the needle sit on rings that set the range back to 360° from 0°, so `angle-270` and `angle-90` mean left and right of the hub, and the needle turns from `-45deg` (E) to `45deg` (F). For a temperature gauge, use C and H and put the red arc on the high end: give it `--o-from: 33.75deg` on its ring. ## Related - [180° gauge](https://zumerlab.com/orbit/examples/gauges/180-degree-gauge.html) - [240° gauge](https://zumerlab.com/orbit/examples/gauges/240-degree-gauge.html) - [Car dashboard](https://zumerlab.com/orbit/examples/dashboard.html) - [Vectors](https://zumerlab.com/orbit/reference/vector.html) --- Source: https://zumerlab.com/orbit/examples/progress/circular-progress-with-background.html # Circular progress with background The everyday progress ring: one `` draws both the fill and the track behind it. ```html
0%0 of 24 MB
``` ```css .stage { gap: 12px; align-content: center; } .stage > .upload { width: min(100%, 100vh - 80px); } /* The fill and its track: two custom properties on one element. */ .upload o-progress { --o-stroke: none; --o-fill: var(--violet); --o-back-fill: color-mix(in srgb, var(--violet) 14%, transparent); } .upload .readout strong { font-size: clamp(28px, 22cqmin, 60px); } ``` ```js const ring = document.querySelector('o-progress'), input = document.getElementById('value'); const set = value => { value = Math.max(0, Math.min(100, value)); input.value = value; ring.setAttribute('value', value); ring.setAttribute('aria-valuenow', Math.round(value)); document.getElementById('pct').textContent = Math.round(value) + '%'; document.getElementById('size').textContent = `${(value * .24).toFixed(1)} of 24 MB`; }; input.addEventListener('input', () => set(Number(input.value))); // A fake upload: uneven chunks, like a real network. let timer; document.getElementById('upload').addEventListener('click', () => { clearInterval(timer); set(0); timer = setInterval(() => { set(Number(input.value) + Math.random() * 3); if (Number(input.value) >= 100) clearInterval(timer); }, 60); }); set(64); ``` ## How it's built - One [``](https://zumerlab.com/orbit/reference/progress.html) on `orbit-10`, 1.6 lanes thick. `value` drives the fill and the element redraws when it changes. - `--o-fill` colors the fill and `--o-back-fill` the track. A faint tint of the same color says how much is left without competing with it. - `shape="circle"` rounds both ends in proportion to the band. - The readout is a satellite on `orbit-0`, fourteen lanes wide, centered with `.at-center`. - `role="progressbar"` with `aria-valuenow` gives screen readers the same number as the ring. ## Color by value A status ring changes color with the value. The color is a custom property, so one line of script does it. ```html
``` ```css .row { display: flex; justify-content: center; gap: 6%; } .row > .bigbang { width: min(28%, 100vh - 40px); } .status o-progress { --o-stroke: none; --o-fill: var(--c); --o-back-fill: color-mix(in srgb, var(--c) 14%, transparent); } .status .readout strong { font-size: clamp(16px, 20cqmin, 34px); color: var(--c); } ``` ```js for (const dial of document.querySelectorAll('.status')) { const value = Number(dial.dataset.value); const color = value < 30 ? 'var(--coral)' : value < 70 ? 'var(--amber)' : 'var(--accent)'; dial.style.setProperty('--c', color); dial.innerHTML = `
${value}%
`; } ``` ## Related - [Dashed progress bar](https://zumerlab.com/orbit/examples/progress/dashed-circular-progress-bar.html): progress in steps - [Progress with handle](https://zumerlab.com/orbit/examples/progress/circular-progress-with-handle.html): a circular slider - [Stacked progress bars](https://zumerlab.com/orbit/examples/progress/stacked-circular-progress-bars.html) - [Progress](https://zumerlab.com/orbit/reference/progress.html) --- Source: https://zumerlab.com/orbit/examples/progress/dashed-circular-progress-bar.html # Dashed circular progress bar Progress counted in steps, for onboarding, quizzes or streaks: ten arcs split the ring, one per step. ```html
0 of 10steps done
``` ```css .stage { gap: 12px; align-content: center; } .stage > .steps { width: min(100%, 100vh - 80px); } .steps o-arc { --o-stroke: none; --o-fill: #1d2630; } .steps o-arc.done { --o-fill: var(--blue); } /* The step in progress breathes. */ .steps o-arc.now { --o-fill: color-mix(in srgb, var(--blue) 45%, #1d2630); animation: breathe 1.4s ease-in-out infinite alternate; } @keyframes breathe { to { opacity: .45; } } .steps .readout strong { font-size: clamp(26px, 18cqmin, 52px); } ``` ```js const total = 10; document.getElementById('dashes').innerHTML = ''.repeat(total); const dashes = [...document.querySelectorAll('#dashes o-arc')], meter = document.querySelector('.steps'); let done = 0; const set = n => { done = Math.max(0, Math.min(total, n)); dashes.forEach((dash, i) => { dash.classList.toggle('done', i < done); dash.classList.toggle('now', i === done); }); meter.setAttribute('aria-valuenow', done); document.getElementById('done').textContent = `${done} of ${total}`; document.getElementById('label').textContent = done === total ? 'all done' : 'steps done'; }; document.getElementById('next').addEventListener('click', () => set(done + 1)); document.getElementById('back').addEventListener('click', () => set(done - 1)); set(6); ``` ## How it's built - Ten [``](https://zumerlab.com/orbit/reference/arc.html) without `value` share the ring equally, so ten arcs are ten steps. Twelve arcs would be twelve. - `--o-gap: 8` on the ring opens the space between dashes, for all of them at once. - Each step is a class: `.done` fills it, `.now` makes the next one breathe with a CSS animation. - The universe is a `role="progressbar"` with `aria-valuemax="10"`, so the count is read as steps, not percent. To name each step, add a second ring further out with one satellite per arc. Same count, same ring: each name sits over its dash. ## Related - [Progress with background](https://zumerlab.com/orbit/examples/progress/circular-progress-with-background.html): a continuous value - [240° gauge](https://zumerlab.com/orbit/examples/gauges/240-degree-gauge.html): steps on a partial range - [Arcs](https://zumerlab.com/orbit/reference/arc.html) --- Source: https://zumerlab.com/orbit/examples/progress/circular-progress-with-handle.html # Circular progress with handle A circular slider: a progress ring and a handle on the same lane, so the handle always sits where the fill ends. ```html
0%brightness

Drag the handle or use the arrow keys.

``` ```css .stage { gap: 8px; align-content: center; } .stage > .dimmer { width: min(100%, 100vh - 70px); touch-action: none; cursor: grab; } .controls { margin: 0; } .dimmer o-progress { --o-stroke: none; --o-fill: var(--amber); --o-back-fill: #1d2630; } .handle-ring { rotate: var(--turn, 0deg); } .handle { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--text); box-shadow: 0 0 0 4px var(--amber), 0 4px 14px #000; } .dimmer:focus-visible { outline: none; } .dimmer:focus-visible .handle { box-shadow: 0 0 0 4px var(--amber), 0 0 0 8px color-mix(in srgb, var(--amber) 35%, transparent); } .dimmer .readout strong { font-size: clamp(28px, 22cqmin, 60px); } ``` ```js const slider = document.querySelector('.dimmer'), ring = slider.querySelector('o-progress'); let value = 0; const set = v => { value = Math.max(0, Math.min(100, Math.round(v))); ring.setAttribute('value', value); // Progress and angle-N both start at the top and grow clockwise: 1% is 3.6°. slider.style.setProperty('--turn', value * 3.6 + 'deg'); slider.setAttribute('aria-valuenow', value); document.getElementById('level').textContent = value + '%'; }; const fromPointer = event => { const box = slider.getBoundingClientRect(); const angle = Math.atan2(event.clientX - (box.left + box.width / 2), (box.top + box.height / 2) - event.clientY) * 180 / Math.PI; const next = ((angle + 360) % 360) / 3.6; // Don't jump from 100 to 0 across the top. if (Math.abs(next - value) < 50) set(next); }; slider.addEventListener('pointerdown', event => { slider.setPointerCapture(event.pointerId); fromPointer(event); }); slider.addEventListener('pointermove', event => { if (slider.hasPointerCapture(event.pointerId)) fromPointer(event); }); slider.addEventListener('keydown', event => { const step = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 }[event.key]; if (step) { event.preventDefault(); set(value + step); } if (event.key === 'Home') set(0); if (event.key === 'End') set(100); }); set(60); ``` ## How it's built - The [``](https://zumerlab.com/orbit/reference/progress.html) and the handle are on two rings of the same lane, `orbit-10`, so they line up at any size. - The band is 1.2 lanes thick and the handle 2 lanes wide, set with `--o-size-ratio` on each ring, so the handle stands out past the edges. - A [satellite](https://zumerlab.com/orbit/reference/satellite.html) alone on a ring sits at the top. Turning its ring with `rotate` moves it smoothly to any angle: `value × 3.6deg`, the same angle where the fill ends. - The universe is the `role="slider"`: focusable, with arrow keys, Page Up/Down, Home and End, and pointer capture for dragging. ## Related - [Progress with background](https://zumerlab.com/orbit/examples/progress/circular-progress-with-background.html) - [Knob with progress bar](https://zumerlab.com/orbit/examples/knobs/knob-with-progress-bar.html): the same value as a knob - [Build a knob](https://zumerlab.com/orbit/guide.html): drag and keyboard, step by step --- Source: https://zumerlab.com/orbit/examples/progress/stacked-circular-progress-bars.html # Stacked circular progress bars Three metrics around one center, each ring labeled in the open quarter where it starts. ```html
CPU
Memory
Disk
web-01live
``` ```css .stage > .server { width: min(100%, 100vh - 56px); } .cpu { --c: var(--accent); } .memory { --c: var(--violet); } .disk { --c: var(--amber); } .server o-progress { --o-stroke: none; --o-fill: var(--c); --o-back-fill: color-mix(in srgb, var(--c) 14%, transparent); } .label o-arc { --o-fill: transparent; --o-stroke: none; --o-color: var(--c); font: 600 13px system-ui, sans-serif; } .server .readout strong { font-size: clamp(18px, 14cqmin, 30px); } ``` ```js // Each metric drifts a little every second, like a live feed. const metrics = [['cpu', 42], ['memory', 68], ['disk', 81]]; const tick = () => { for (const metric of metrics) { const [name, value] = metric; const drift = name === 'disk' ? .2 : 9; metric[1] = Math.round(Math.max(3, Math.min(97, value + (Math.random() - .5) * drift))); const ring = document.querySelector('o-progress.' + name); ring.setAttribute('value', metric[1]); ring.setAttribute('aria-valuenow', metric[1]); const label = document.querySelector('o-arc.' + name); label.textContent = `${label.textContent.split(' ')[0]} ${metric[1]}%`; } }; tick(); setInterval(tick, 1200); ``` ## How it's built - Three [``](https://zumerlab.com/orbit/reference/progress.html) on lanes 11, 9 and 7 of one [`.gravity-spot`](https://zumerlab.com/orbit/reference/gravity-spot.html): concentric with no positioning. - `--o-range: 270deg` and `--o-size-ratio: 1.6` are set once on the system. Every ring picks them up, and 1.6 lanes on rings two lanes apart leaves a thin gap. - Each label is an [``](https://zumerlab.com/orbit/reference/arc.html) on the same lane as its ring: the arc is as long as its text. `text-anchor="end"` ends it at `--o-from: 356deg`, just before the ring starts. - Changing `value` or the text is all the script does; Orbit redraws the arcs. ## Related - [Progress with background](https://zumerlab.com/orbit/examples/progress/circular-progress-with-background.html) - [Layered status rings](https://zumerlab.com/orbit/examples/layered-status-rings.html): rings stacked in depth instead - [Progress rings demo](https://zumerlab.com/orbit/demos/01-minimal-gauge.html): three full rings with a total - [Orbit Kit rings](https://zumerlab.com/orbit/kit/rings.html) --- Source: https://zumerlab.com/orbit/examples/knobs/knob-with-progress-bar.html # Knob with progress bar A gain knob whose arc grows from the center position, to the left for cuts and to the right for boosts. ```html
0 dBgain
``` ```css .stage > .knob { width: min(100%, 100vh - 40px); touch-action: none; cursor: grab; } .knob:active { cursor: grabbing; } .ticks .vector { background: var(--faint); } .ticks .vector:is(:first-child, :nth-child(13), :last-child) { background: var(--muted); height: 2px; } .track o-arc { --o-stroke: none; --o-fill: #1d2630; } .gain o-arc { --o-stroke: none; --o-fill: var(--c, var(--accent)); } .knob.cut { --c: var(--coral); } /* Brushed metal: a conic gradient on a round capsule. */ .body { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle, transparent 58%, #0a0d11 60%, #0a0d11 61%, transparent 63%), conic-gradient(#3a4552, #222a33, #4a5664, #20272f, #3a4552, #222a33, #4a5664, #20272f, #3a4552); box-shadow: 0 18px 40px -12px #000, inset 0 1px 0 #55606e; } .turn { rotate: var(--turn, 0deg); transition: rotate .08s linear; } .pointer { height: 4px; border-radius: 2px; background: var(--text); } .knob:focus-visible { outline: none; } .knob:focus-visible .body { box-shadow: 0 0 0 3px var(--accent), 0 18px 40px -12px #000; } .knob .readout strong { font-size: clamp(20px, 7cqmin, 30px); } ``` ```js document.getElementById('ticks').innerHTML = '
'.repeat(25); const knob = document.querySelector('.knob'), gain = knob.querySelector('.gain'); let value = 0; const set = v => { value = Math.max(-12, Math.min(12, Math.round(v * 2) / 2)); const angle = value / 12 * 135; // ±135° from the top // The arc's ring starts at the top for a boost, and at the value for a cut. gain.style.setProperty('--o-from', `${value < 0 ? 360 + angle : 0}deg`); gain.style.setProperty('--o-range', `${Math.abs(angle)}deg`); knob.classList.toggle('cut', value < 0); knob.style.setProperty('--turn', angle + 'deg'); knob.setAttribute('aria-valuenow', value); document.getElementById('db').textContent = `${value > 0 ? '+' : value < 0 ? '−' : ''}${Math.abs(value)} dB`; }; // Pointer angle around the center, -135° to 135°. const fromPointer = event => { const box = knob.getBoundingClientRect(); const angle = Math.atan2(event.clientX - (box.left + box.width / 2), (box.top + box.height / 2) - event.clientY) * 180 / Math.PI; if (Math.abs(angle) <= 135) set(angle / 135 * 12); }; knob.addEventListener('pointerdown', event => { knob.setPointerCapture(event.pointerId); fromPointer(event); }); knob.addEventListener('pointermove', event => { if (knob.hasPointerCapture(event.pointerId)) fromPointer(event); }); knob.addEventListener('dblclick', () => set(0)); knob.addEventListener('wheel', event => { event.preventDefault(); set(value - Math.sign(event.deltaY) * .5); }, { passive: false }); knob.addEventListener('keydown', event => { const step = { ArrowUp: .5, ArrowRight: .5, ArrowDown: -.5, ArrowLeft: -.5, PageUp: 3, PageDown: -3 }[event.key]; if (step) { event.preventDefault(); set(value + step); } if (event.key === 'Home') set(-12); if (event.key === 'End') set(12); }); set(4.5); ``` Drag, scroll or use the arrow keys. Double-click to reset to 0 dB. ## How it's built - The ticks and the dark track share `range-270 from-225`, the usual knob sweep with the gap at the bottom. `fit-range` puts the first and last of the 25 [`.vector`](https://zumerlab.com/orbit/reference/vector.html)s on the two ends. - The colored arc is one [``](https://zumerlab.com/orbit/reference/arc.html) without `value`, so it fills its ring's range. The script sets that ring's `--o-from` and `--o-range`: from the top for a boost, from the value back to the top for a cut. - The body is a [satellite](https://zumerlab.com/orbit/reference/satellite.html) fifteen lanes wide on `orbit-0`. A satellite is a universe too, so the `.gravity-spot` inside it places the pointer relative to the knob, and `rotate` on that system turns it. - The universe is the `role="slider"`, with pointer capture for dragging and the usual keys. ## Related - [LED knob](https://zumerlab.com/orbit/examples/knobs/led-knob.html): discrete steps - [Circular progress with handle](https://zumerlab.com/orbit/examples/progress/circular-progress-with-handle.html) - [Knob demo](https://zumerlab.com/orbit/demos/05-knob.html): a 0 to 100 volume knob - [Build a knob](https://zumerlab.com/orbit/guide.html): a step-by-step guide - [Orbit Kit knob](https://zumerlab.com/orbit/kit/knob.html) --- Source: https://zumerlab.com/orbit/examples/knobs/led-knob.html # LED knob A knob with discrete steps: eleven LEDs, one per position, lit up to the current one, and a pointer that snaps between them. ```html
0drive
``` ```css .stage > .knob { width: min(100%, 100vh - 40px); touch-action: none; cursor: grab; } .knob:active { cursor: grabbing; } .leds .capsule { width: 100%; aspect-ratio: 1; border-radius: 50%; background: #27313c; box-shadow: inset 0 1px 2px #000; transition: background .12s, box-shadow .12s; } .leds .on .capsule { --led: var(--accent); background: radial-gradient(circle at 50% 40%, #fff 0 12%, var(--led) 45%); box-shadow: 0 0 10px var(--led); } .leds .on:nth-child(n + 9) .capsule { --led: var(--amber); } .leds .on:nth-child(11) .capsule { --led: var(--coral); } .skirt { width: 100%; aspect-ratio: 1; border-radius: 50%; background: repeating-conic-gradient(#28313c 0 4deg, #1b222b 4deg 8deg); box-shadow: 0 18px 40px -12px #000; } .cap { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 50% 30%, #2f3a47, #161c23 75%); box-shadow: inset 0 1px 0 #46525f; } .turn { rotate: var(--turn, -145deg); transition: rotate .15s cubic-bezier(.3, 1.6, .5, 1); } .pointer { height: 4px; border-radius: 2px; background: var(--text); } .knob:focus-visible { outline: none; } .knob:focus-visible .skirt { box-shadow: 0 0 0 3px var(--accent), 0 18px 40px -12px #000; } .knob .readout strong { font-size: clamp(20px, 7cqmin, 30px); } ``` ```js const leds = document.getElementById('leds'); leds.innerHTML = '
'.repeat(11); const knob = document.querySelector('.knob'); let step = 0; const set = s => { step = Math.max(0, Math.min(10, Math.round(s))); // 290° over ten steps, starting at 215° (-145° from the top). knob.style.setProperty('--turn', `${-145 + step * 29}deg`); [...leds.children].forEach((led, i) => led.classList.toggle('on', i <= step && step > 0)); knob.setAttribute('aria-valuenow', step); document.getElementById('step').textContent = step; }; const fromPointer = event => { const box = knob.getBoundingClientRect(); const angle = Math.atan2(event.clientX - (box.left + box.width / 2), (box.top + box.height / 2) - event.clientY) * 180 / Math.PI; if (Math.abs(angle) <= 160) set((angle + 145) / 29); }; knob.addEventListener('pointerdown', event => { knob.setPointerCapture(event.pointerId); fromPointer(event); }); knob.addEventListener('pointermove', event => { if (knob.hasPointerCapture(event.pointerId)) fromPointer(event); }); knob.addEventListener('wheel', event => { event.preventDefault(); set(step - Math.sign(event.deltaY)); }, { passive: false }); knob.addEventListener('keydown', event => { const delta = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }[event.key]; if (delta) { event.preventDefault(); set(step + delta); } if (event.key === 'Home') set(0); if (event.key === 'End') set(10); }); set(5); ``` Drag, scroll or use the arrow keys. The knob snaps to the nearest step. ## How it's built - Eleven [satellites](https://zumerlab.com/orbit/reference/satellite.html) on `orbit-11` with `range-290 from-215 fit-range`: the first and last sit on the two ends of the sweep, the rest are spread evenly between them. - A lit LED is a class on its satellite. `:nth-child(n + 9)` and `:nth-child(11)` turn the top steps amber and coral. - The knob is a satellite on `orbit-0` with a `.gravity-spot` inside: its cap is a centered satellite of that inner system, and its pointer a [`.vector`](https://zumerlab.com/orbit/reference/vector.html) on `orbit-9`. - The pointer's ring turns with `rotate`, 29° per step, the same spacing as the LEDs. A springy easing makes each step snap. ## Related - [Knob with progress bar](https://zumerlab.com/orbit/examples/knobs/knob-with-progress-bar.html): continuous values - [Dashed progress bar](https://zumerlab.com/orbit/examples/progress/dashed-circular-progress-bar.html): steps without a knob - [Orbit Kit knob](https://zumerlab.com/orbit/kit/knob.html) --- Source: https://zumerlab.com/orbit/examples/piemenu.html # Pie menu A two-level menu: each wedge opens its options on an outer ring that covers only its own slice. ```html
``` ```css .stage > .menu { width: min(100%, 100vh - 32px); } .menu o-arc { --o-fill: #1b232d; --o-stroke: #2c3846; --o-color: var(--muted); font: 600 13px system-ui, sans-serif; transition: scale .18s cubic-bezier(.2, .8, .2, 1); } /* Orbit places arcs with rotate, so a hover scale adds to it. */ .menu o-arc:is(:hover, :focus-visible) { --o-fill: #253040; --o-color: var(--text); scale: 1.03; outline: none; } .main o-arc[aria-expanded="true"] { --o-fill: var(--accent); --o-stroke: var(--accent); --o-color: var(--bg); } .sub o-arc[aria-checked="true"] { --o-color: var(--accent); --o-stroke: var(--accent); } .swatches o-arc { --o-fill: var(--c); --o-stroke: var(--c); } .swatches o-arc:is(:hover, :focus-visible) { --o-fill: var(--c); --o-stroke: var(--text); } /* Closed submenus fade and shrink toward the center. */ .sub { opacity: 0; visibility: hidden; scale: .9; transition: opacity .2s, scale .2s, visibility .2s; } .sub.open { opacity: 1; visibility: visible; scale: 1; } .sample { font-size: 28px; color: var(--c, var(--text)); transition: font-size .15s; } ``` ```js const colors = [['White', 'var(--text)'], ['Teal', 'var(--accent)'], ['Blue', 'var(--blue)'], ['Violet', 'var(--violet)'], ['Coral', 'var(--coral)'], ['Amber', 'var(--amber)']]; document.getElementById('color').innerHTML = colors.map(([name, c]) => ``).join(''); const sample = document.getElementById('sample'); const wedges = [...document.querySelectorAll('.main o-arc')]; let size = 28; const open = wedge => { const name = wedge && wedge.getAttribute('aria-expanded') !== 'true' ? wedge.dataset.open : null; wedges.forEach(w => w.setAttribute('aria-expanded', w.dataset.open === name)); document.querySelectorAll('.sub').forEach(sub => { sub.classList.toggle('open', sub.id === name); sub.querySelectorAll('o-arc').forEach(o => { o.tabIndex = sub.id === name ? 0 : -1; }); }); }; // One handler for every option, by what its data says. const choose = option => { const { size: step, toggle, color } = option.dataset; if (step) size = Math.max(16, Math.min(44, size + Number(step))); if (toggle) option.setAttribute('aria-checked', option.getAttribute('aria-checked') !== 'true'); if (color) sample.style.setProperty('--c', color); const on = name => document.querySelector(`[data-toggle="${name}"]`).getAttribute('aria-checked') === 'true'; sample.style.fontSize = size + 'px'; sample.style.fontWeight = on('bold') ? 800 : 400; sample.style.fontStyle = on('italic') ? 'italic' : 'normal'; sample.style.textDecoration = on('underline') ? 'underline' : 'none'; }; document.querySelectorAll('.menu o-arc').forEach(arc => { const act = () => (arc.dataset.open ? open(arc) : choose(arc)); arc.addEventListener('click', act); arc.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); act(); } if (event.key === 'Escape') { open(null); wedges[0].focus(); } }); }); open(wedges[1]); choose(document.querySelector('[data-toggle="bold"]')); ``` Click a wedge, or Tab to it and press Enter. Escape closes the submenu. ## How it's built - Three [``](https://zumerlab.com/orbit/reference/arc.html) without `value` split the inner ring into 120° wedges. Their text is drawn along the arc, and `flip` keeps the bottom one readable. - Each submenu is a ring on `orbit-10` with `--o-range: 120deg` and the `--o-from` of its wedge. Its options split that slice evenly, so they always sit right outside the wedge that opened them. - `interactive` lets an arc take the pointer and focus; the rest of the square stays click-through. Hover and focus use `scale`, which adds to the `rotate` Orbit places arcs with. - Closed submenus are faded and scaled to 90% from the center, so opening one grows it outward. - Wedges are `menuitem`s with `aria-expanded`, style options are `menuitemcheckbox`es, and only the open submenu is in the Tab order. ## Related - [Radial menu demo](https://zumerlab.com/orbit/demos/03-radial-menu.html): one level, with icons - [Orbit Kit menu](https://zumerlab.com/orbit/kit/menu.html): a ready-made radial menu - [Knobs and menus](https://zumerlab.com/orbit/examples/knobs/index.html) - [Arcs](https://zumerlab.com/orbit/reference/arc.html) --- Source: https://zumerlab.com/orbit/examples/charts/donut-chart.html # Donut chart Arcs with a `value` follow each other around the ring, so a donut chart is just your data in order. ```html
    ``` ```css .chart { display: flex; align-items: center; justify-content: center; gap: 6%; } .chart > .donut { width: min(56%, 100vh - 40px); flex: none; } .donut o-arc { --o-stroke: none; --o-fill: var(--c); } .share { font: 600 12px var(--mono); color: var(--bg); } .key { display: grid; gap: 18px; } .legend { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; font-size: 14px; } .legend li { display: grid; grid-template-columns: 10px 1fr auto; gap: 10px; align-items: center; } .legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--c); } .legend span { font: 13px var(--mono); color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; } .key .controls { gap: 8px; } button[aria-pressed="true"] { border-color: var(--accent); } ``` ```js const rooms = [['Kitchen', 'var(--accent)'], ['Living', 'var(--blue)'], ['Office', 'var(--violet)'], ['Bedroom', 'var(--amber)'], ['Bath', 'var(--coral)']]; const weeks = [[52, 38, 31, 14, 9], [41, 46, 22, 19, 12]]; // kWh per room const ring = document.getElementById('ring'), shares = document.getElementById('shares'), legend = document.getElementById('legend'); ring.innerHTML = rooms.map(([, c]) => ``).join(''); legend.innerHTML = rooms.map(([name, c]) => `
  • ${name}
  • `).join(''); let shown = weeks[0]; const draw = data => { const total = data.reduce((a, b) => a + b, 0); let before = 0; shares.innerHTML = ''; data.forEach((kwh, i) => { const share = kwh / total * 100; ring.children[i].setAttribute('value', share); // A share label goes to the middle of its arc: everything before it, plus half of it. const middle = Math.round((before + share / 2) * 3.6); if (share >= 6) shares.insertAdjacentHTML('beforeend', `
    `); before += share; legend.children[i].querySelector('span').textContent = Math.round(kwh) + ' kWh'; }); document.getElementById('total').textContent = Math.round(total); }; // Switching weeks eases from one dataset to the other. const show = target => { const from = shown, start = performance.now(); const step = now => { const t = Math.min(1, (now - start) / 600), ease = 1 - (1 - t) ** 3; shown = from.map((v, i) => v + (target[i] - v) * ease); draw(shown); if (t < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); }; document.querySelectorAll('[data-week]').forEach(button => button.addEventListener('click', () => { document.querySelectorAll('[data-week]').forEach(b => b.setAttribute('aria-pressed', b === button)); show(weeks[button.dataset.week]); })); draw(shown); ``` ## How it's built - Each room is an [``](https://zumerlab.com/orbit/reference/arc.html). Values are shares of 100 (or of `max`), and each arc starts where the one before it ends. - One ring on lane 8, four lanes thick, leaves room in the middle for the total. `--o-gap: 2` draws the thin line between segments. - The shares are [satellites](https://zumerlab.com/orbit/reference/satellite.html) on the same lane, turned with `angle-N` to the middle of their arc: the running total before it plus half its own share, times 3.6. - Switching weeks only rewrites the `value`s, a little at a time; Orbit restacks the arcs on every frame. ## Related - [Pie chart](https://zumerlab.com/orbit/examples/charts/pie-chart.html): the hole closed - [Sunburst chart](https://zumerlab.com/orbit/examples/charts/sunburst-chart.html): nested data - [Donut chart demo](https://zumerlab.com/orbit/demos/02-donut-multilayer.html): a hoverable legend - [Orbit Kit donut](https://zumerlab.com/orbit/kit/donut.html): a ready-made component --- Source: https://zumerlab.com/orbit/examples/charts/pie-chart.html # Pie chart A pie is a donut with the hole closed: each slice is an arc that reaches all the way to the center. ```html
    1,240people asked
      ``` ```css .chart { display: flex; align-items: center; justify-content: center; gap: 7%; } .chart > .pie { width: min(54%, 100vh - 72px); flex: none; } .pie o-arc { --o-stroke: var(--bg); --o-stroke-width: 2; --o-fill: var(--c); transition: translate .25s cubic-bezier(.3, 1.4, .5, 1), opacity .2s; } /* The slice in focus slides out along its middle; the others step back. */ .pie o-arc.on { translate: var(--dx) var(--dy); } .pie.focus o-arc:not(.on) { opacity: .45; } .pie o-arc:focus-visible { outline: none; } .key { display: grid; gap: 16px; justify-items: start; } .key .readout { justify-items: start; } .key .readout strong { font-size: 34px; } .legend { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 14px; } .legend li { display: grid; grid-template-columns: 10px 1fr auto; gap: 10px; align-items: center; padding: 3px 8px; margin-left: -8px; border-radius: 6px; cursor: default; } .legend li.on { background: var(--panel); } .legend i { width: 10px; height: 10px; border-radius: 3px; background: var(--c); } .legend span { font: 13px var(--mono); color: var(--muted); padding-left: 12px; } ``` ```js const data = [['Car', 35, 'var(--blue)'], ['Transit', 25, 'var(--accent)'], ['Bike', 15, 'var(--lime)'], ['Walk', 15, 'var(--amber)'], ['Remote', 10, 'var(--violet)']]; const slices = document.getElementById('slices'), legend = document.getElementById('legend'), pie = document.querySelector('.pie'); let before = 0; data.forEach(([name, share, c]) => { // The direction of the slice's middle, for sliding it out 4% of the chart. const middle = (before + share / 2) * 3.6 * Math.PI / 180; const dx = (Math.sin(middle) * 4).toFixed(2), dy = (-Math.cos(middle) * 4).toFixed(2); slices.insertAdjacentHTML('beforeend', ``); legend.insertAdjacentHTML('beforeend', `
    • ${name}${share}%
    • `); before += share; }); const arcs = [...slices.children], rows = [...legend.children]; const focus = i => { pie.classList.toggle('focus', i >= 0); arcs.forEach((arc, j) => arc.classList.toggle('on', i === j)); rows.forEach((row, j) => row.classList.toggle('on', i === j)); document.getElementById('share').textContent = i < 0 ? '1,240' : data[i][1] + '%'; document.getElementById('name').textContent = i < 0 ? 'people asked' : data[i][0].toLowerCase(); }; [...arcs, ...rows].forEach((el, k) => { el.addEventListener('pointerenter', () => focus(k % data.length)); el.addEventListener('pointerleave', () => focus(-1)); }); arcs.forEach((arc, i) => { arc.addEventListener('focus', () => focus(i)); arc.addEventListener('blur', () => focus(-1)); }); ``` Hover a slice or a row, or Tab through the slices. ## How it's built - Five [``](https://zumerlab.com/orbit/reference/arc.html) that add up to 100, stacked in DOM order on one ring. - `inner-orbit` puts the outer edge of each arc on `orbit-11`, and `--o-size-ratio: 11` makes it eleven lanes thick: all the way in. Lower it to 6 and you have a donut of the same size. - A thin `--o-stroke` in the background color separates the slices instead of `--o-gap`, which would leave wedges that narrow to nothing at the center. - `interactive` lets each slice take the pointer and focus. Orbit places arcs with `rotate`, so a `translate` of yours adds to it: the slice in focus slides out along its middle. ## Related - [Donut chart](https://zumerlab.com/orbit/examples/charts/donut-chart.html) - [Sunburst chart](https://zumerlab.com/orbit/examples/charts/sunburst-chart.html) - [Arcs](https://zumerlab.com/orbit/reference/arc.html) --- Source: https://zumerlab.com/orbit/examples/charts/sunburst-chart.html # Sunburst chart Nested data, one ring per level: if both rings count out of the same total, each part lines up with its category on its own. ```html
      0 hawake, one week
      ``` ```css .stage > .sunburst { width: min(100%, 100vh - 32px); } .level o-arc { --o-stroke: none; --o-fill: var(--c); --o-color: var(--bg); font: 600 12px system-ui, sans-serif; transition: opacity .2s; } .level o-arc:focus-visible { outline: none; --o-stroke: var(--text); --o-stroke-width: 2; } /* A branch in focus: everything else steps back. */ .sunburst[data-focus] o-arc:not(.on) { opacity: .3; } .sunburst .readout strong { font-size: clamp(22px, 16cqmin, 40px); } ``` ```js // Hours in a week, awake. Categories and their parts. const data = [ ['Work', 'var(--blue)', [['Meetings', 12], ['Focus', 22], ['Email', 8]]], ['Home', 'var(--amber)', [['Cooking', 8], ['Chores', 9], ['Family', 14]]], ['Rest', 'var(--accent)', [['Reading', 7], ['Sport', 6], ['Friends', 10], ['Screens', 16]]], ]; const sum = list => list.reduce((total, [, hours]) => total + hours, 0); const total = data.reduce((all, [, , parts]) => all + sum(parts), 0); const inner = document.getElementById('inner'), outer = document.getElementById('outer'); let before = 0; // Text on the lower half is flipped to read the right way up. const arc = (name, hours, color, branch) => { const share = hours / total * 100, middle = (before + share / 2) * 3.6; const flip = middle > 90 && middle < 270 ? 'flip' : ''; return `${name}`; }; data.forEach(([name, color, parts], b) => { const start = before; inner.insertAdjacentHTML('beforeend', arc(name, sum(parts), color, b)); // The parts: lighter shades of the category, in the same order. parts.forEach(([part, hours], i) => { outer.insertAdjacentHTML('beforeend', arc(part, hours, `color-mix(in srgb, ${color} ${85 - i * 12}%, var(--bg))`, b)); before += hours / total * 100; }); before = start + sum(parts) / total * 100; }); const chart = document.querySelector('.sunburst'), arcs = [...chart.querySelectorAll('o-arc')]; const focus = target => { if (target) chart.dataset.focus = ''; else delete chart.dataset.focus; // A category lights its parts; a part lights itself and its category. arcs.forEach(a => a.classList.toggle('on', !!target && a.dataset.branch === target.dataset.branch && (target.parentElement === inner || a === target || a.parentElement === inner))); document.getElementById('hours').textContent = (target ? target.dataset.hours : total) + ' h'; document.getElementById('what').textContent = target ? target.textContent.trim().toLowerCase() : 'awake, one week'; }; arcs.forEach(a => { a.addEventListener('pointerenter', () => focus(a)); a.addEventListener('focus', () => focus(a)); a.addEventListener('pointerleave', () => focus(null)); a.addEventListener('blur', () => focus(null)); }); focus(null); ``` Hover or Tab through the rings: a category lights up its parts. ## How it's built - Two rings, two levels: `orbit-6` for categories and `orbit-10` for their parts. `--o-size-ratio: 3.6` on the [`.gravity-spot`](https://zumerlab.com/orbit/reference/gravity-spot.html) makes each band 3.6 lanes thick; the rings are four lanes apart, which leaves a thin line between levels. - Every [``](https://zumerlab.com/orbit/reference/arc.html) gets its share of the whole week as `value`. Arcs with values stack in DOM order, so when the parts are listed in the same order as their categories, each one lands inside its parent with no angles to compute. - The names are the arcs' own text. `flip` turns the text of arcs on the lower half so it reads the right way up, and `--o-color` sets its color. - Each part is a shade of its category made with `color-mix()`, so a branch reads as one without a legend. - `interactive` and `tabindex` make every slice hoverable and focusable; the center tells you what you are on. ## Related - [Donut chart](https://zumerlab.com/orbit/examples/charts/donut-chart.html): one level - [Pie chart](https://zumerlab.com/orbit/examples/charts/pie-chart.html) - [Arcs](https://zumerlab.com/orbit/reference/arc.html) --- Source: https://zumerlab.com/orbit/examples/watches/minimalist-analog-clock.html # Minimalist analog clock A clock is a ring of ticks, a ring of numbers and three hands that share a center. This one tells the real time. ```html
      ``` ```css .face { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 50% 35%, #1a222c, #0e1318 72%); box-shadow: inset 0 0 0 1px #26313d; } .ticks .vector { background: var(--faint); } .ticks .vector.major { height: 3px; background: var(--text); } .numbers .capsule { font-size: 55cqmin; font-variant-numeric: tabular-nums; color: var(--muted); } .hand { rotate: var(--turn, 0deg); } .hand .vector { height: clamp(2px, 1.4cqmin, 6px); border-radius: 3px; background: var(--text); } .second .vector { height: 2px; background: var(--amber); } .running .second { transition: rotate .25s cubic-bezier(.4, 2, .6, 1); } .pin { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--amber); } ``` ```js // Write `count` items into a ring, from the top, clockwise. const fill = (ring, count, item) => { document.querySelector(ring).innerHTML = Array.from({ length: count }, (_, i) => item(i)).join(''); }; fill('.ticks', 60, (i) => i % 5 ? '
      ' : '
      '); fill('.numbers', 12, (i) => `
      ${i || 12}
      `); // Seconds since midnight, counted up from the page load, so no hand ever jumps back. const clock = document.querySelector('.clock'); const start = new Date(); const base = start.getHours() * 3600 + start.getMinutes() * 60 + start.getSeconds(); const turn = (hand, deg) => clock.querySelector(hand).style.setProperty('--turn', `${deg}deg`); function tick() { const s = base + Math.round((Date.now() - start) / 1000); turn('.hour', s / 120); turn('.minute', s / 10); turn('.second', s * 6); clock.setAttribute('aria-label', `Clock showing ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`); } tick(); requestAnimationFrame(() => clock.classList.add('running')); setInterval(tick, 1000); ``` ## How it's built - The face is a satellite in the center with `--o-size-ratio: 24`, as wide as the universe. See [satellites](https://zumerlab.com/orbit/reference/satellite.html). - Sixty [vectors](https://zumerlab.com/orbit/reference/vector.html) on `orbit-11`, spaced 6° apart by Orbit. `inner-orbit` hangs them inward from the ring; every fifth one is longer and brighter. - The numbers are twelve satellites on `orbit-9`. The first child sits at the top, so the list starts with 12. - Each hand is a single vector on its own ring. A lone vector points up; the ring turns with `rotate` and takes the hand with it. The lane and `--o-size-ratio` set where the hand starts and how long it is. ## Variations A quieter dial: no numbers, twelve hour marks and two hands. ```html
      ``` ```css .face { width: 100%; aspect-ratio: 1; border-radius: 50%; background: #10151b; box-shadow: inset 0 0 0 1px #26313d; } .ticks .vector, .hand .vector { height: clamp(3px, 1.6cqmin, 6px); border-radius: 3px; } .ticks .vector { background: var(--accent); } .hand { rotate: var(--turn, 0deg); } .hand .vector { background: var(--text); } ``` ```js document.querySelector('.ticks').innerHTML = '
      '.repeat(12); const clock = document.querySelector('.clock'); function tick() { const d = new Date(), s = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); clock.querySelector('.hour').style.setProperty('--turn', `${s / 120}deg`); clock.querySelector('.minute').style.setProperty('--turn', `${s / 10}deg`); clock.setAttribute('aria-label', `Clock showing ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`); } tick(); setInterval(tick, 1000); ``` ## Related - [Chronograph](https://zumerlab.com/orbit/examples/watches/chronograph-watch-face.html): three sub-dials and a stopwatch - [Roman numeral clock](https://zumerlab.com/orbit/examples/watches/roman-numeral-clock-face.html) - [Smartwatch face](https://zumerlab.com/orbit/examples/watches/smartwatch-face.html): curved text along the bezel - [Compass demo](https://zumerlab.com/orbit/demos/09-compass.html): a whole dial that turns - [Orbit Kit clock](https://zumerlab.com/orbit/kit/clock.html) --- Source: https://zumerlab.com/orbit/examples/watches/chronograph-watch-face.html # Chronograph watch face Each sub-dial is a whole clock inside a satellite, with its own center, ticks and hand. Start the stopwatch and three systems run at once. ```html
      00:00.0
      ``` ```css .stage { gap: 12px; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .face { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 50% 35%, #1a222c, #0e1318 72%); box-shadow: inset 0 0 0 1px #26313d; } .ticks .vector { background: var(--faint); } .ticks .vector.major { height: 3px; background: var(--text); } .subdials .face { background: #0b0f14; box-shadow: inset 0 0 0 1px var(--line); } .subdials .ticks .vector { background: var(--muted); } .subdials .numbers .capsule { font: max(9px, 70cqmin) var(--mono); color: var(--muted); } .subdials .ticks .vector.major { height: 2px; background: var(--text); } .hand { rotate: var(--turn, 0deg); } .hand .vector { height: clamp(2px, 1.4cqmin, 6px); border-radius: 3px; background: var(--text); } .chrono .vector, .subdials .hand .vector { height: 2px; background: var(--coral); } .pin { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--coral); } .elapsed { font-variant-numeric: tabular-nums; } ``` ```js const $ = (s) => document.querySelector(s); const repeat = (count, item) => Array.from({ length: count }, (_, i) => item(i)).join(''); const ticks = (count, every) => repeat(count, (i) => i % every ? '
      ' : '
      '); // A sub-dial: a satellite with its own gravity-spot. Its lanes are twelfths of the satellite. const subdial = (name, count, every, numbers) => ` `; $('.watch > .gravity-spot > .ticks').innerHTML = ticks(60, 5); $('.subdials').innerHTML = subdial('minutes', 30, 5, [30, 10, 20]) + // 3 o'clock: 30 minutes a turn subdial('tenths', 10, 5, []) + // 6 o'clock: one second a turn subdial('seconds', 60, 15, [60, 15, 30, 45]); // 9 o'clock: the running seconds const turn = (selector, deg) => $(selector).style.setProperty('--turn', `${deg}deg`); // The time: hours, minutes and the small seconds. function time() { const d = new Date(), s = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); turn('.watch .hour', s / 120); turn('.watch .minute', s / 10); turn('.seconds .hand', s * 6); } time(); setInterval(time, 1000); // The stopwatch: the big center hand, minutes at 3, tenths at 6. let elapsed = 0, since = null; function chrono() { const ms = elapsed + (since ? performance.now() - since : 0); turn('.chrono', ms / 1000 * 6); turn('.minutes .hand', ms / 60000 * 12); turn('.tenths .hand', ms / 1000 * 360); const m = Math.floor(ms / 60000), s = (ms / 1000) % 60; $('.elapsed').value = `${String(m).padStart(2, '0')}:${s.toFixed(1).padStart(4, '0')}`; if (since) requestAnimationFrame(chrono); } $('.toggle').addEventListener('click', () => { if (since) { elapsed += performance.now() - since; since = null; } else { since = performance.now(); requestAnimationFrame(chrono); } $('.toggle').textContent = since ? 'Stop' : 'Start'; }); $('.reset').addEventListener('click', () => { elapsed = 0; if (since) since = performance.now(); chrono(); }); chrono(); ``` ## How it's built - The three sub-dials are satellites on `orbit-5`. `from-90 range-180 fit-range` puts the first at three o'clock, the last at nine and one between. See [rings](https://zumerlab.com/orbit/reference/orbit.html). - `--o-size-ratio: 6` on that ring makes each one six lanes wide. The `.gravity-spot` inside a satellite takes the satellite as its universe and starts from the defaults, so the sub-dial uses the same `orbit-11` ticks and `orbit-4` hand as a big clock. See [satellites](https://zumerlab.com/orbit/reference/satellite.html). - Every hand is one [vector](https://zumerlab.com/orbit/reference/vector.html) on a ring turned with `rotate`. The stopwatch only sets an angle per hand, sixty times a second while it runs. - The JS tab writes the ticks and the sub-dials, so the markup of a sub-dial appears once. ## Related - [Minimalist analog clock](https://zumerlab.com/orbit/examples/watches/minimalist-analog-clock.html): the same hands, one system - [Smartwatch face](https://zumerlab.com/orbit/examples/watches/smartwatch-face.html) - [Nested systems demo](https://zumerlab.com/orbit/demos/08-nested-orbits.html) - [Gravity spot](https://zumerlab.com/orbit/reference/gravity-spot.html): every system starts from the defaults --- Source: https://zumerlab.com/orbit/examples/watches/roman-numeral-clock-face.html # Roman numeral clock On a classic Roman dial each numeral stands along the radius, base toward the center, so VI reads upside down. Two capsule classes give it that turn; the button takes them off. ```html
      ``` ```css .stage { gap: 12px; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .face { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 50% 35%, #1d2026, #101216 72%); box-shadow: inset 0 0 0 1px #2c2a26; } .rule { border: 1px solid color-mix(in srgb, var(--amber) 45%, transparent); } .ticks .vector { background: color-mix(in srgb, var(--amber) 45%, transparent); } .ticks .vector:nth-child(5n + 1) { height: 3px; background: var(--amber); } .numerals .capsule { font-size: 46cqmin; font-weight: 600; color: var(--text); transition: rotate .5s; } .hand { rotate: var(--turn, 0deg); } .hand .vector { height: clamp(3px, 1.6cqmin, 7px); border-radius: 4px; background: var(--amber); } .pin { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--amber); } ``` ```js const $ = (s) => document.querySelector(s); const numerals = ['XII', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI']; $('.numerals').innerHTML = numerals.map((n) => `
      ${n}
      `).join(''); $('.ticks').innerHTML = '
      '.repeat(60); // Toggle the two classes that turn the numerals. $('.toggle').addEventListener('click', (event) => { const on = event.target.getAttribute('aria-pressed') !== 'true'; event.target.setAttribute('aria-pressed', on); event.target.textContent = on ? 'Along the radius' : 'Upright'; document.querySelectorAll('.numerals .satellite').forEach((s) => s.classList.toggle('spin-lock', on)); document.querySelectorAll('.numerals .capsule').forEach((c) => c.classList.toggle('flip', on)); }); const clock = $('.clock'); function tick() { const d = new Date(), s = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); $('.hour').style.setProperty('--turn', `${s / 120}deg`); $('.minute').style.setProperty('--turn', `${s / 10}deg`); clock.setAttribute('aria-label', `Clock showing ${d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`); } tick(); setInterval(tick, 1000); ``` ## How it's built - `spin-lock` on a satellite turns its capsule with its position on the ring, so the text runs along the radius. See [capsules](https://zumerlab.com/orbit/reference/capsule.html). - `flip` on the capsule adds half a turn, base toward the center. Without it XII would stand on its head at the top. - The railroad track is two empty rings with a border, `orbit-11` and `orbit-11 shrink-50`, half a lane apart. `shrink-N` on a ring only moves it toward the previous lane. - Sixty [vectors](https://zumerlab.com/orbit/reference/vector.html) hang between them with `inner-orbit`. `:nth-child(5n + 1)` picks the hour marks, starting at the top. ## Variations A California dial keeps numerals at XII, III, VI and IX and puts plain marks in between. Empty satellites hold the places, so the numerals stay where they belong. ```html
      ``` ```css .face { width: 100%; aspect-ratio: 1; border-radius: 50%; background: #101216; box-shadow: inset 0 0 0 1px #2c2a26; } .numerals .capsule { font-size: 46cqmin; font-weight: 600; color: var(--text); } .mark { width: 22%; aspect-ratio: 1 / 3; border-radius: 2px; background: var(--amber); } ``` ```js const numerals = { 0: 'XII', 3: 'III', 6: 'VI', 9: 'IX' }; document.querySelector('.numerals').innerHTML = Array.from({ length: 12 }, (_, i) => `
      ${numerals[i] || ''}
      `).join(''); ``` ## Related - [Minimalist analog clock](https://zumerlab.com/orbit/examples/watches/minimalist-analog-clock.html) - [Chronograph](https://zumerlab.com/orbit/examples/watches/chronograph-watch-face.html) - [Circular timer](https://zumerlab.com/orbit/examples/circular_time.html): numbers along the radius on a wheel that turns - [Capsules](https://zumerlab.com/orbit/reference/capsule.html): `flip`, `spin-lock` and the other turns --- Source: https://zumerlab.com/orbit/examples/watches/smartwatch-face.html # Smartwatch face Text written along the bezel: the digital time at the top, the date at the bottom and two complications on the sides, all `` elements that follow the live clock. ```html
      ``` ```css .face { width: 100%; aspect-ratio: 1; border-radius: 50%; background: #000; box-shadow: inset 0 0 0 2px #1d242d; } .text o-arc { --o-fill: none; } .time, .date { font-weight: 600; font-variant-numeric: tabular-nums; } .time { color: var(--text); } .date { color: var(--muted); } .band { --o-size-ratio: .5; } .band o-progress { --o-stroke: none; --o-fill: var(--c); --o-back-fill: color-mix(in srgb, var(--c) 18%, transparent); } .value { font-size: 13px; color: var(--c); font-variant-numeric: tabular-nums; } .steps { --c: var(--lime); } .battery { --c: var(--amber); } .marks .vector { height: 3px; border-radius: 2px; background: var(--muted); } .hand { rotate: var(--turn, 0deg); } .hand .vector { height: clamp(3px, 1.6cqmin, 7px); border-radius: 4px; background: var(--text); } .second .vector { height: 2px; background: var(--accent); } .pin { width: 100%; aspect-ratio: 1; border-radius: 50%; background: var(--accent); } ``` ```js const $ = (s) => document.querySelector(s); $('.marks').innerHTML = '
      '.repeat(12); // Complications. Changing value, or the text of an arc, redraws it. const steps = 6840, battery = 72; $('.band.steps o-progress').setAttribute('value', steps); $('.band.battery o-progress').setAttribute('value', battery); $('.value.steps o-arc').textContent = `${steps.toLocaleString('en')} steps`; $('.value.battery o-arc').textContent = `${battery}%`; const watch = $('.watch'); function tick() { const d = new Date(), s = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); $('.hour').style.setProperty('--turn', `${s / 120}deg`); $('.minute').style.setProperty('--turn', `${s / 10}deg`); $('.second').style.setProperty('--turn', `${s * 6}deg`); const time = d.toLocaleTimeString('en', { hour: 'numeric', minute: '2-digit' }); const date = d.toLocaleDateString('en', { weekday: 'short', day: 'numeric', month: 'short' }); // Only touch the arcs when the text changes. if ($('.time o-arc').textContent !== time) { $('.time o-arc').textContent = time; $('.date o-arc').textContent = date; watch.setAttribute('aria-label', `Smartwatch face: ${time}, ${date}, ${steps} steps, battery ${battery}%`); } } tick(); setInterval(tick, 1000); ``` ## How it's built - Text inside an [``](https://zumerlab.com/orbit/reference/arc.html) is drawn along it. `range="auto"` makes the arc as long as its text and `text-anchor="middle"` centers it on the `from-N` of its ring, so the time stays centered at the top whatever its length. - `flip` on the date turns the text so it reads left to right at the bottom of the dial. - The complications are [``](https://zumerlab.com/orbit/reference/progress.html) bands on an 80° range of `orbit-11`, half a lane thick, with their value as curved text one lane in. - The script only writes text and attributes. Orbit watches the arcs and redraws them on the next frame. ## Related - [Minimalist analog clock](https://zumerlab.com/orbit/examples/watches/minimalist-analog-clock.html): the hands, explained - [Chronograph](https://zumerlab.com/orbit/examples/watches/chronograph-watch-face.html) - [Circular year calendar](https://zumerlab.com/orbit/examples/calendar/circular-year-calendar.html): more curved text - [Progress rings demo](https://zumerlab.com/orbit/demos/01-minimal-gauge.html) - [Orbit Kit clock](https://zumerlab.com/orbit/kit/clock.html) --- Source: https://zumerlab.com/orbit/examples/circular_time.html # Circular timer A timer you set by turning a wheel. The numbers, the ticks and the time sector are three rings that share one `--o-from`, so changing it turns them as one. ```html
      0:15left
      ``` ```css .stage { gap: 12px; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .timer { touch-action: none; cursor: grab; user-select: none; } .timer:focus-visible { outline: none; } .timer:focus-visible .wheel { box-shadow: inset 0 0 0 2px var(--accent); } .wheel { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 50% 35%, #1a222c, #0e1318 72%); box-shadow: inset 0 0 0 1px #26313d; } .sector { --o-stroke: none; --o-fill: color-mix(in srgb, var(--accent) 30%, transparent); } .ticks .vector { background: var(--faint); } .ticks .vector.major { height: 2px; background: var(--text); } .numbers .capsule { font-size: 50cqmin; color: var(--muted); font-variant-numeric: tabular-nums; } .pointer { width: 70%; aspect-ratio: 1; background: var(--accent); clip-path: polygon(0 0, 100% 0, 50% 100%); } .hub { width: 100%; aspect-ratio: 1; border-radius: 50%; place-content: center; background: var(--panel); box-shadow: 0 0 0 1px var(--line); } .hub strong { font-size: clamp(18px, 26cqmin, 40px); } ``` ```js const $ = (s) => document.querySelector(s); const timer = $('.timer'); // Twelve numbers along the radius and sixty ticks. $('.numbers').innerHTML = Array.from({ length: 12 }, (_, i) => `
      ${i * 5}
      `).join(''); $('.ticks').innerHTML = Array.from({ length: 60 }, (_, i) => `
      `).join(''); // left: degrees of time on the wheel, 6 per second, so a full turn is a minute. let left = 90, running = null; function render() { const from = (360 - left) % 360; document.querySelectorAll('.turn').forEach((ring) => ring.style.setProperty('--o-from', `${from}deg`)); $('.sector').setAttribute('value', left); const s = Math.ceil(left / 6); $('.left').textContent = `0:${String(s).padStart(2, '0')}`; timer.setAttribute('aria-valuenow', s); $('.play').textContent = running ? 'Pause' : 'Start'; } const set = (deg) => { left = Math.max(0, Math.min(359, Math.round(deg))); render(); }; const stop = () => { clearInterval(running); running = null; render(); }; function start() { if (!left) return; running = setInterval(() => { left -= 1; if (left <= 0) { left = 0; stop(); } else render(); }, 1000 / 6); render(); } $('.play').addEventListener('click', () => (running ? stop() : start())); $('.reset').addEventListener('click', () => { stop(); set(0); }); // Drag: the wheel follows the change in the pointer's angle around the center. const angle = (e) => { const r = timer.getBoundingClientRect(); return Math.atan2(e.clientY - r.top - r.height / 2, e.clientX - r.left - r.width / 2) * 180 / Math.PI; }; let grab = null; timer.addEventListener('pointerdown', (e) => { stop(); timer.setPointerCapture(e.pointerId); grab = { at: angle(e), left }; }); timer.addEventListener('pointermove', (e) => { if (!grab) return; const turned = ((angle(e) - grab.at + 540) % 360) - 180; set(grab.left - turned); grab = { at: angle(e), left }; }); timer.addEventListener('pointerup', () => { grab = null; }); // Keys: arrows for a second, Page Up and Down for ten. timer.addEventListener('keydown', (e) => { const step = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 }[e.key]; if (step) { e.preventDefault(); stop(); set(left + step * 6); } if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); running ? stop() : start(); } }); render(); ``` ## How it's built - The numbers, the ticks and the sector are on three rings with the class `turn`. The script sets one `--o-from` on all three, and Orbit places their children from that angle. See [rings](https://zumerlab.com/orbit/reference/orbit.html). - The sector is an [``](https://zumerlab.com/orbit/reference/progress.html) whose `value` is the angle set. On `orbit-5` with `--o-size-ratio: 10` the band reaches from the center to lane 10. - The numbers stand along the radius with `spin-lock` on the satellite and `flip` on the capsule. - The pointer and the readout are on rings that never get the new `--o-from`, so they stay put. - The whole universe is the slider: drag it, or focus it and use the arrow keys. Space starts and pauses. After a [SwiftUI original](https://github.com/harfyzz/CircularTimer). Thanks to [Andriy Tyurnikov](https://github.com/andriytyurnikov), who proposed the challenge. ## Related - [Watches and clocks](https://zumerlab.com/orbit/examples/watches/index.html) - [Progress with handle](https://zumerlab.com/orbit/examples/progress/circular-progress-with-handle.html) - [Knob demo](https://zumerlab.com/orbit/demos/05-knob.html): another dial you drag - [Orbit Kit timer](https://zumerlab.com/orbit/kit/timer.html) --- Source: https://zumerlab.com/orbit/examples/calendar/circular-year-calendar.html # Circular year calendar A year on one dial: seasons, months and the days of the month on three rings. Step through the days and the day ring re-spaces itself for 28, 30 or 31 days. ```html
      Winter Spring Summer Autumn
      ``` ```css .stage { gap: 12px; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .face { width: 100%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 50% 35%, #1a222c, #0e1318 72%); box-shadow: inset 0 0 0 1px #26313d; } .days .capsule { width: 70%; aspect-ratio: 1; border-radius: 50%; place-content: center; font-size: 55cqmin; color: var(--muted); font-variant-numeric: tabular-nums; } .days .today .capsule { background: var(--accent); color: var(--bg); font-weight: 650; } .year { --o-stroke: none; --o-fill: var(--accent); --o-back-fill: var(--line); } .months o-arc { --o-fill: transparent; --o-stroke: none; color: var(--muted); font-size: 13px; } .months o-arc.current { --o-fill: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); } .seasons o-arc { --o-stroke: none; --o-fill: color-mix(in srgb, var(--c) 16%, transparent); color: var(--c); font-size: 12px; } .winter { --c: var(--blue); } .spring { --c: var(--lime); } .summer { --c: var(--amber); } .autumn { --c: var(--coral); } .readout strong { font-size: clamp(18px, 20cqmin, 36px); } ``` ```js const $ = (s) => document.querySelector(s); const names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; // Labels in the lower half (April to September) are flipped to read left to right. $('.months').innerHTML = names.map((m, i) => `= 3 && i <= 8 ? 'flip' : ''}>${m}`).join(''); let day = new Date(); function show() { const y = day.getFullYear(), m = day.getMonth(), d = day.getDate(); const length = new Date(y, m + 1, 0).getDate(); // Rewrite the day ring only when the month changes; Orbit spreads 28 to 31 days evenly. const days = $('.days'); if (days.children.length !== length || days.dataset.month !== `${y}-${m}`) { days.dataset.month = `${y}-${m}`; days.innerHTML = Array.from({ length }, (_, i) => `
      ${i + 1}
      `).join(''); } [...days.children].forEach((s, i) => s.classList.toggle('today', i + 1 === d)); [...$('.months').children].forEach((arc, i) => arc.classList.toggle('current', i === m)); const dayOfYear = Math.round((new Date(y, m, d) - new Date(y, 0, 1)) / 864e5) + 1; $('.year').setAttribute('value', dayOfYear); $('.date').textContent = `${d} ${names[m]}`; $('.weekday').textContent = `${day.toLocaleDateString('en', { weekday: 'long' })}, ${y}`; } document.querySelectorAll('[data-step]').forEach((button) => button.addEventListener('click', () => { const step = Number(button.dataset.step); day = step ? new Date(day.getFullYear(), day.getMonth(), day.getDate() + step) : new Date(); show(); })); show(); ``` ## How it's built - The days are satellites on `orbit-11`. The script writes as many as the month has, and Orbit spaces them evenly, so the ring is always full. See [satellites](https://zumerlab.com/orbit/reference/satellite.html). - Months and seasons are [``](https://zumerlab.com/orbit/reference/arc.html) elements without a `value`: sibling arcs share their ring equally. Text inside an arc is drawn along it, and `flip` keeps the lower half readable. - `from-350` turns the season ring so winter starts on 21 December, ten days before January. - The thin band between the months and the days is an [``](https://zumerlab.com/orbit/reference/progress.html) set to the day of the year. ## Related - [Chronodex day planner](https://zumerlab.com/orbit/examples/calendar/index.html#chronodex): a day as wedges - [Smartwatch face](https://zumerlab.com/orbit/examples/watches/smartwatch-face.html): more curved text - [Arcs](https://zumerlab.com/orbit/reference/arc.html) --- Source: https://zumerlab.com/orbit/examples/calendar/ # Calendars Time goes around, so a circle suits it: a year as a dial of months, a day as wedges of hours. - [Circular year calendar](https://zumerlab.com/orbit/examples/calendar/circular-year-calendar.html): Seasons, months and days on one dial; step through the year. - [Chronodex day planner](https://zumerlab.com/orbit/examples/calendar/index.html#chronodex): A day as wedges; click one to plan that hour. ## Chronodex day planner A Chronodex is a radial day planner from paper journaling: each hour is a wedge, and you fill the wedge to plan that hour. Here every wedge is an `` you can click. ```html

      ``` ```css .stage { gap: 4px; } .stage > .bigbang { width: min(100%, 100vh - 64px); } .controls { margin: 0; } .planner o-arc { --o-stroke: none; --o-fill: color-mix(in srgb, var(--text) 7%, transparent); --o-gap: 2; } .planner o-arc:hover { --o-fill: color-mix(in srgb, var(--text) 14%, transparent); } .planner o-arc[aria-pressed="true"] { --o-fill: color-mix(in srgb, var(--accent) 70%, transparent); } .planner o-arc:focus-visible { outline: none; --o-stroke: var(--accent); --o-stroke-width: 2; } .label { pointer-events: none; } .label .capsule { font-size: 12px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } .morning-label .capsule { font-size: 11px; } ``` ```js const $ = (s) => document.querySelector(s); const hourName = (h) => `${h % 12 || 12}${h < 12 ? 'am' : 'pm'}`; const wedge = (h, lanes) => ``; // Wedge k covers the hour from k to k+1 on the clock face. Thickness cycles 3, 4, 5 lanes. const outer = Array.from({ length: 12 }, (_, k) => (k >= 10 ? k : k + 12)); $('.hours').innerHTML = outer.map((h, k) => wedge(h, 3 + (k % 3))).join(''); $('.morning').innerHTML = [7, 8, 9].map((h) => wedge(h, 3)).join(''); // One ring of labels per thickness, centered on the wedges (from 15° + 30° per step). $('.labels').outerHTML = [0, 1, 2].map((t) => `
      ${[0, 3, 6, 9].map((k) => `
      ${hourName(outer[k + t])}
      `).join('')}
      `).join('') + `
      ${[7, 8, 9].map((h) => `
      ${h}
      `).join('')}
      `; // Click, Enter or Space to plan an hour. const count = () => { $('.count').value = `${document.querySelectorAll('[aria-pressed="true"]').length} hours planned`; }; const toggle = (arc) => { arc.setAttribute('aria-pressed', arc.getAttribute('aria-pressed') !== 'true'); count(); }; document.querySelectorAll('.planner o-arc').forEach((arc) => { arc.addEventListener('click', () => toggle(arc)); arc.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(arc); } }); }); [8, 13, 14, 18].forEach((h) => toggle($(`[data-hour="${h}"]`))); ``` ### How it's built - Twelve [``](https://zumerlab.com/orbit/reference/arc.html) elements without a `value` share `orbit-4`, 30° each. `outer-orbit` makes every wedge start on the ring and grow outward, and its own `--o-size-ratio` sets how far: 3, 4 or 5 lanes. - The morning is three more arcs on `orbit-1`, three lanes long, limited to a quarter with `from-210 range-90`. - `interactive` makes an arc take clicks; `role="button"`, `tabindex` and `aria-pressed` make it a toggle for the keyboard and screen readers. - The labels are satellites on three rings, one per wedge length, each turned with `from-N` to the middle of its wedges. ## Related - [Watches and clocks](https://zumerlab.com/orbit/examples/watches/index.html) - [Radial menu demo](https://zumerlab.com/orbit/demos/03-radial-menu.html): arcs that respond to hover and keys - [Arcs](https://zumerlab.com/orbit/reference/arc.html) --- Source: https://zumerlab.com/orbit/examples/mandalas/geometric-square-mandala.html # Geometric square mandala One pattern of nested squares, copied and turned. The copies sit on one ring, and Orbit spreads them over a quarter turn: pick the number of folds and the angles follow. ```html
      Folds
      ``` ```css .stage { gap: 12px; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .square { border: 1px solid color-mix(in srgb, var(--c) 75%, transparent); background: color-mix(in srgb, var(--c) 4%, transparent); } .outer { --c: var(--violet); } .inner { --c: var(--accent); } .diamond { --c: var(--amber); } .dot { background: var(--amber); } .controls button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } ``` ```js // One copy of the pattern: a satellite as big as the universe, with its own system inside. // spin-lock turns a satellite, and everything in it, by its angle on the ring. const pattern = `
      `; const mandala = document.querySelector('.mandala'); const buttons = document.querySelectorAll('.controls button'); function folds(n) { document.querySelector('.folds').innerHTML = pattern.repeat(n); mandala.setAttribute('aria-label', `Square mandala, ${n} ${n === 1 ? 'fold' : 'folds'}`); buttons.forEach((b) => b.setAttribute('aria-pressed', b.textContent === String(n))); } buttons.forEach((b) => b.addEventListener('click', () => folds(Number(b.textContent)))); folds(4); ``` ## How it's built - Each copy is an `at-center` satellite with `--o-size-ratio: 24`, as wide as the universe. The `.gravity-spot` inside it has the same lanes as the main system. See [satellites](https://zumerlab.com/orbit/reference/satellite.html). - All copies sit on one `orbit-0` with `range-90`. Orbit gives each one an angle, 90° divided by the number of copies, and `spin-lock` turns the copy by that angle. - A square is a satellite with the `box` class. `at-center` keeps it on the center, so only `--o-size-ratio` sets its size, in lanes. - The diamonds are squares with `spin-lock angle-45`: the same turn, for a single satellite. ## Related - [Radial symmetry mandala](https://zumerlab.com/orbit/examples/mandalas/radial-symmetry-mandala.html) - [Starter patterns](https://zumerlab.com/orbit/examples/mandalas/index.html#starters) - [Chemical structure](https://zumerlab.com/orbit/examples/chemical_structures.html): systems inside satellites again - [Capsules](https://zumerlab.com/orbit/reference/capsule.html): `spin-lock` --- Source: https://zumerlab.com/orbit/examples/mandalas/radial-symmetry-mandala.html # Radial symmetry mandala Symmetry for free: put copies of a satellite on a ring and Orbit spaces them evenly. Change the number of copies and the whole pattern redraws with six, eight or twelve folds. ```html
      Folds
      ``` ```css .stage { gap: 12px; } .stage > .bigbang { width: min(100%, 100vh - 84px); } .circle { border: 1px solid var(--c, var(--accent)); background: var(--panel); } .stacks .circle:nth-child(even) { --c: var(--violet); } .triangles .side { background: var(--amber); } .dot { background: var(--amber); } .controls button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } ``` ```js // A copy: a satellite with its own system inside, so its circles are counted in its own lanes. const stack = (sizes) => `
      ${sizes.map((s) => `
      `).join('')}
      `; // A triangle: three sides on the outer ring of a nested system. spin-lock turns it with its place. const triangle = `
      `; const $ = (s) => document.querySelector(s); const buttons = document.querySelectorAll('.controls button'); function folds(n) { $('.stacks').innerHTML = stack([24, 18, 12, 6]).repeat(n); $('.stacks.small').innerHTML = stack([24, 14]).repeat(n); $('.triangles').innerHTML = triangle.repeat(n); // Half a step round, so the triangles show between the stacks. $('.triangles').style.setProperty('--o-from', `${180 / n}deg`); $('.mandala').setAttribute('aria-label', `Mandala with ${n}-fold symmetry`); buttons.forEach((b) => b.setAttribute('aria-pressed', b.textContent === String(n))); } buttons.forEach((b) => b.addEventListener('click', () => folds(Number(b.textContent)))); folds(8); ``` ## How it's built - Copies of a satellite on one ring are spread evenly, so the symmetry needs no angles: the buttons only change how many copies the script writes. - Each copy holds its own `.gravity-spot`, which reaches the edge of the satellite. Its circles are `at-center` satellites sized in the copy's own lanes: 24 is as wide as the copy. See [gravity spot](https://zumerlab.com/orbit/reference/gravity-spot.html). - The triangles are three [`.side`](https://zumerlab.com/orbit/reference/side.html) elements on `orbit-12` of the nested system. `spin-lock` turns each copy with its place on the ring, so all of them point outward. - `--o-from` on the triangle ring turns it half a step, so the corners show between the stacks. The stacks have a background and sit above it, hiding the rest. ## Related - [Geometric square mandala](https://zumerlab.com/orbit/examples/mandalas/geometric-square-mandala.html) - [Starter patterns](https://zumerlab.com/orbit/examples/mandalas/index.html#starters) - [Team map demo](https://zumerlab.com/orbit/demos/07-satellites.html): satellites spread on three rings - [Sides](https://zumerlab.com/orbit/reference/side.html) --- Source: https://zumerlab.com/orbit/examples/mandalas/ # Mandalas Mandalas are repetition and nesting, which is what Orbit does: put copies of a satellite on a ring and they spread evenly; put a system inside a satellite and it gets its own center. Everything here is `
      ` elements with borders. - [Geometric square mandala](https://zumerlab.com/orbit/examples/mandalas/geometric-square-mandala.html): Nested squares, copied and turned; one to eight folds. - [Radial symmetry mandala](https://zumerlab.com/orbit/examples/mandalas/radial-symmetry-mandala.html): Rings of circles with six, eight or twelve folds. - [Starter patterns](https://zumerlab.com/orbit/examples/mandalas/index.html#starters): A hexagonal flower and a rosette. ## Starter patterns ### Hexagonal flower Six circles around a hexagon. The hexagon is six `.side` elements on the outer ring of a system nested in the center satellite. ```html
      ``` ```css .petals .satellite { border: 1px solid var(--accent); background: color-mix(in srgb, var(--accent) 6%, transparent); } .hexagon .side { height: 2px; background: var(--amber); } ``` ```js document.querySelector('.petals').innerHTML = '
      '.repeat(6); document.querySelector('.hexagon').innerHTML = '
      '.repeat(6); ``` - Circles on `orbit-5` with `--o-size-ratio: 10` pass through the center and touch their neighbors' centers: the flower of life. - Sides connect the places of neighbors on a ring, so six of them on `orbit-12` of the nested system draw a hexagon that fits its satellite. See [sides](https://zumerlab.com/orbit/reference/side.html). ### Rosette Large circles on one small ring overlap into a rosette. Drag the slider: Orbit respaces them as their number changes. ```html
      ``` ```css .stage { gap: 10px; } .stage > .bigbang { width: min(100%, 100vh - 76px); } .circles .satellite { border: 1px solid color-mix(in srgb, var(--violet) 70%, transparent); } .circles .satellite:nth-child(odd) { border-color: color-mix(in srgb, var(--accent) 70%, transparent); } ``` ```js const input = document.querySelector('input'), output = document.querySelector('output'); function draw() { const n = Number(input.value); document.querySelector('.circles').innerHTML = '
      '.repeat(n); document.querySelector('.rosette').setAttribute('aria-label', `Rosette of ${n} circles`); output.value = n; } input.addEventListener('input', draw); draw(); ``` - Every circle is twelve lanes wide on a ring of radius five, so each one reaches past the center to the other side. The count only changes the angle between them. ## Related - [Sides](https://zumerlab.com/orbit/reference/side.html) and [satellites](https://zumerlab.com/orbit/reference/satellite.html) - [Sci-fi and game interfaces](https://zumerlab.com/orbit/use-cases/sci-fi-game-uis.html) --- Source: https://zumerlab.com/orbit/examples/dashboard.html # Car dashboard A dashboard screen made of nested systems: a clock and two pods, each a satellite with its own rings inside. ```html
      Sun 11 Apr6:5912° outside
      63%214 km
      21.5°
      ``` ```css .stage > .dash { width: min(100%, (100vh - 32px) * 2); aspect-ratio: 2 / 1; } .face, .pod { border-radius: 50%; background: radial-gradient(circle at 50% 35%, #18202a, var(--panel) 70%); box-shadow: inset 0 0 0 1px var(--line), 0 20px 50px -24px #000; } .seconds .vector { background: var(--faint); height: 2px; transition: background .3s; } .seconds .vector.past { background: var(--accent); } .clock { gap: 4px; } .clock strong { font-size: clamp(30px, 24cqmin, 92px); font-weight: 300; letter-spacing: -.04em; } .clock span { font-size: clamp(11px, 5cqmin, 16px); } .pod .readout strong { font-size: clamp(14px, 16cqmin, 30px); } .pod .readout span { font-size: clamp(10px, 8cqmin, 13px); } .pod o-progress { --o-stroke: none; --o-back-fill: #1d2630; } .battery { --o-fill: var(--accent); } .heat { --o-fill: var(--amber); } .pod button { width: 100%; aspect-ratio: 1; padding: 0; border-radius: 50%; font-size: clamp(13px, 9cqmin, 20px); line-height: 1; } ``` ```js // Sixty ticks; the ones up to the current second light up. const seconds = document.getElementById('seconds'); seconds.innerHTML = '
      '.repeat(60); const tick = () => { const now = new Date(); [...seconds.children].forEach((v, i) => v.classList.toggle('past', i <= now.getSeconds())); document.getElementById('time').textContent = now.toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit' }); document.getElementById('date').textContent = now.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }); }; tick(); setInterval(tick, 1000); // Climate: 16° to 28°, drawn on the pod's 270° ring. let temp = 21.5; const setTemp = t => { temp = Math.max(16, Math.min(28, t)); document.querySelector('.heat').setAttribute('value', (temp - 16) / 12 * 100); document.getElementById('temp').textContent = temp.toFixed(1) + '°'; }; document.querySelectorAll('[data-step]').forEach(b => b.addEventListener('click', () => setTemp(temp + Number(b.dataset.step)))); setTemp(temp); ``` ## How it's built - The [universe](https://zumerlab.com/orbit/reference/bigbang.html) is 2:1, so the reach is its height. The clock fills the middle, and the pods sit on `orbit-17`, past the edge of the circle but inside the wide box. - Two [satellites](https://zumerlab.com/orbit/reference/satellite.html) alone on a ring are spread half a turn apart. `--o-from: 90deg` on the ring turns that pair to right and left. - The clock and each pod are satellites with a [`.gravity-spot`](https://zumerlab.com/orbit/reference/gravity-spot.html) inside. Every system starts from the defaults, so their rings are counted from their own edge: the seconds ticks, the battery ring and the climate arc. - The climate buttons are two satellites on one ring of the pod, so they land at the top and the bottom with no angles. - The clock ticks with a one-second timer that lights the [`.vector`](https://zumerlab.com/orbit/reference/vector.html)s up to the current second. Inspired by a design found on [Pinterest](https://pin.it/dP9PlNjFc); an earlier version is on [CodePen](https://codepen.io/zircle/pen/eYqwYPB). ## Related - [Fuel gauge](https://zumerlab.com/orbit/examples/gauges/fuel-gauge.html) - [Watch faces](https://zumerlab.com/orbit/examples/watches/index.html) - [Dashboards](https://zumerlab.com/orbit/use-cases/dashboards.html) --- Source: https://zumerlab.com/orbit/examples/layered-status-rings.html # Layered status rings Several channels on one radial object, stacked in depth: the one in front is the one you read, and an alert moves its channel to the front. A home, a car or a server has channels that compete for attention: climate, lights, security. As separate widgets they split the user's focus. Here they share one object, and the order says what matters now. ```html
      21.5°Climate
      ``` ```css .stage { gap: 4px; align-content: center; } .stage > .home { width: min(100%, 100vh - 70px); } .home o-progress { --o-stroke: var(--text); --o-back-stroke: var(--line); } .segments o-arc { --o-fill: var(--line); --o-stroke: none; --o-gap: 24; --o-size-ratio: .16; } .segments o-arc.on { --o-fill: var(--text); } .segments o-arc.fail { --o-fill: var(--coral); } /* The alert ripple: only on the ring in front. */ .alert.layer-front::after { content: ''; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--coral); animation: ripple .9s ease-out infinite; } @keyframes ripple { from { scale: 1; opacity: .8; } to { scale: 1.45; opacity: 0; } } .home .readout strong { font-size: clamp(18px, 13cqmin, 30px); } .home.alerting .readout strong { color: var(--coral); } ``` ```js // Six segments per channel: lights that are on, windows that are closed. const segments = (ring, on) => { ring.innerHTML = Array.from({ length: 6 }, (_, i) => ``).join(''); }; segments(document.getElementById('lights'), 5); segments(document.getElementById('security'), 6); const home = document.querySelector('.home'); const layers = [...home.querySelectorAll('.depth > [data-depth]')]; const security = document.getElementById('security'); const windowArc = security.children[2]; const button = document.getElementById('alert'); // Order layers front to back and give each its depth. const order = list => { list.forEach((layer, depth) => { layer.dataset.depth = depth; layer.classList.toggle('layer-front', depth === 0); }); document.getElementById('name').textContent = list[0].dataset.name; document.getElementById('value').textContent = list[0].dataset.value; }; button.addEventListener('click', () => { const alerting = security.classList.toggle('alert'); home.classList.toggle('alerting', alerting); windowArc.classList.toggle('fail', alerting); windowArc.classList.toggle('on', !alerting); security.dataset.value = alerting ? 'Window open' : '6 of 6 closed'; button.textContent = alerting ? 'Close the window' : 'Open a window'; // An alert goes in front; resolving it restores the base order. order(alerting ? [security, ...layers.filter(l => l !== security)] : layers); }); ``` ## How it's built - `.depth` on the [`.gravity-spot`](https://zumerlab.com/orbit/reference/gravity-spot.html) turns its rings into layers. Each ring gets a `data-depth`, and `.layer-front` marks the one in front. - All three channels use `orbit-6`. `--o-depth-grow: .42` makes each step back 42% larger (1×, 1.42×, 1.84×), so no two rings share a radius, and the perspective compensation keeps those sizes. - Climate is a single [``](https://zumerlab.com/orbit/reference/progress.html). Lights and Security are six [``](https://zumerlab.com/orbit/reference/arc.html) segments each, spaced with `--o-gap` and thinned with `--o-size-ratio`. - Color belongs to the alert: the failing segment, the ripple and the value in the center turn coral, and nothing else is painted. - Order is data. The script keeps the base order, puts the alerting channel first, and writes `data-depth` from that list. The depth transition is Orbit's own. ## Customization - `--o-depth-step` (default `-110`) changes the spacing in z, and `--o-perspective` (default `900`) the perspective. - `--o-depth-compensate: 0` lets farther layers look smaller. - To hide layers past the third, set `opacity: 0` on `[data-depth="3"]` and above, and show a count instead. Three visible layers is about what people read at a glance. ## Related - [Depth layers](https://zumerlab.com/orbit/craft/depth.html): properties and rules - [Depth layers demo](https://zumerlab.com/orbit/demos/11-depth-layers.html): composite layers with tilt - [Stacked circular progress bars](https://zumerlab.com/orbit/examples/progress/stacked-circular-progress-bars.html): the flat version - [IoT and hardware UI](https://zumerlab.com/orbit/use-cases/iot-and-hardware-ui.html) --- Source: https://zumerlab.com/orbit/examples/scifi.html # Sci-fi interface A HUD made of loose pieces: partial arcs and rows of dashes, each ring at its own distance from the eye. Tilt it and the rings slide past each other. Move the pointer over the frame. ```html
      ``` ```css /* The perspective lives on the stage; --o-perspective on the system matches it. */ .tilt { perspective: 700px; gap: 10px; } .tilt > .bigbang { width: min(100%, 100vh - 80px); } .hud .depth { --o-perspective: 700; } .hud { rotate: x var(--rx, 0deg); transform: rotateY(var(--ry, 0deg)); } .rim { border-color: var(--line); } .dashes .vector { height: 3px; background: color-mix(in srgb, var(--accent) 55%, transparent); } .hud o-arc { --o-stroke: none; } .teal { --o-fill: var(--accent); } .coral { --o-fill: var(--coral); } .faint { opacity: .45; } .none { --o-fill: none; } .shields { --o-stroke: none; --o-fill: var(--accent); --o-back-fill: color-mix(in srgb, var(--accent) 14%, transparent); } .readout strong { font-size: clamp(20px, 24cqmin, 40px); color: var(--accent); } /* Rings turn with rotate; depth uses transform, so the two add up. */ .spin-slow { animation: spin 60s linear infinite; } .spin-back { animation: spin 40s linear infinite reverse; } .paused .spin-slow, .paused .spin-back { animation-play-state: paused; } @keyframes spin { to { rotate: 360deg; } } ``` ```js // The dashes: data-count vectors per ring. document.querySelectorAll('[data-count]').forEach((ring) => { ring.innerHTML = '
      '.repeat(Number(ring.dataset.count)); }); // Tilt toward the pointer, up to 25°. Without a pointer, sway slowly. const stage = document.querySelector('.tilt'), hud = document.querySelector('.hud'); const still = matchMedia('(prefers-reduced-motion: reduce)').matches; let pointer = null, rx = 0, ry = 0, paused = false; const pause = document.querySelector('.pause'); pause.addEventListener('click', () => { paused = hud.classList.toggle('paused'); pause.setAttribute('aria-pressed', paused); pause.textContent = paused ? 'Play' : 'Pause'; }); stage.addEventListener('pointermove', (e) => { const r = stage.getBoundingClientRect(); pointer = [(e.clientX - r.left) / r.width - 0.5, (e.clientY - r.top) / r.height - 0.5]; }); stage.addEventListener('pointerleave', () => { pointer = null; }); function frame(t) { const [x, y] = pointer || (still || paused ? [0, 0] : [Math.sin(t / 2400) * 0.3, Math.cos(t / 3100) * 0.25]); ry += (x * 50 - ry) * 0.08; rx += (-y * 50 - rx) * 0.08; hud.style.setProperty('--rx', `${rx}deg`); hud.style.setProperty('--ry', `${ry}deg`); requestAnimationFrame(frame); } requestAnimationFrame(frame); ``` ## How it's built - Arcs with a `value` and a `from-N` draw a partial band anywhere on a ring; arcs without one split the ring, and a transparent one leaves a gap. See [arcs](https://zumerlab.com/orbit/reference/arc.html). - The dashes are [vectors](https://zumerlab.com/orbit/reference/vector.html) written by the script, 48 on a 240° range and 16 on 100°. - `depth` on the `.gravity-spot` turns each ring into a layer, and `--o-z` places it in front or behind. Layers keep their apparent size, so the face looks flat until it tilts. See [depth](https://zumerlab.com/orbit/craft/depth.html). - `outer-perspective` leaves the perspective to the stage, so the whole universe can turn with `rotateY` and `rotate: x`. - Two rings also spin with a CSS animation on `rotate`, which adds to the depth transform instead of replacing it. The button pauses them and the sway. ## Related - [Abstract orbital map](https://zumerlab.com/orbit/examples/abstract_map.html) - [Layered status rings](https://zumerlab.com/orbit/examples/layered-status-rings.html): depth layers that change order - [Depth layers demo](https://zumerlab.com/orbit/demos/11-depth-layers.html) - [Sci-fi and game interfaces](https://zumerlab.com/orbit/use-cases/sci-fi-game-uis.html) --- Source: https://zumerlab.com/orbit/examples/abstract_map.html # Abstract orbital map A star chart where every planet is a system of its own, with its own rings and moons. Each ring turns at its own speed with a plain CSS animation on `rotate`. ```html
      ``` ```css .stage { gap: 10px; } .stage > .bigbang { width: min(100%, 100vh - 80px); } .line { border-color: var(--line); } .dashed { border-style: dashed; } .ticks .vector { background: var(--faint); } .meridians .vector { background: color-mix(in srgb, var(--line) 70%, transparent); } .star { background: var(--amber); box-shadow: 0 0 24px 6px color-mix(in srgb, var(--amber) 45%, transparent); } .planet { background: radial-gradient(circle at 35% 30%, var(--accent), color-mix(in oklab, var(--accent) 40%, black)); } .planet.small { background: radial-gradient(circle at 35% 30%, var(--violet), #4b3a87); } .moon { background: var(--text); } .label .capsule { font-size: 12px; color: var(--muted); white-space: nowrap; } /* Every .turn ring spins; --time and --dir set its speed and way. */ .turn { animation: spin var(--time) linear infinite var(--dir, normal); } .paused .turn { animation-play-state: paused; } @keyframes spin { to { rotate: 360deg; } } ``` ```js document.querySelector('.ticks').innerHTML = '
      '.repeat(72); document.querySelector('.meridians').innerHTML = '
      '.repeat(12); // Moving content that runs for long should have a way to stop it. const pause = document.querySelector('.pause'); pause.addEventListener('click', () => { const paused = document.querySelector('.map').classList.toggle('paused'); pause.setAttribute('aria-pressed', paused); pause.textContent = paused ? 'Play' : 'Pause'; }); ``` ## How it's built - Each planet is a satellite with its own `.gravity-spot`, so it gets its own twelve lanes for a core, a dashed ring and moons. See [satellites](https://zumerlab.com/orbit/reference/satellite.html). - Rings are drawn by their border. An empty ring is just a circle, so the chart's guides are rings with nothing on them; the ticks and meridians are [vectors](https://zumerlab.com/orbit/reference/vector.html). - A ring with `rotate` turns everything on it. The `.turn` class animates it, and `--time` and `--dir` on each ring set the speed and the direction. - The label rides on `orbit-16` of the planet's system, past its edge. That ring turns back as fast as the planet's ring, so the two cancel out and the word stays upright beside the planet. Inspired by a design found on [Pinterest](https://pin.it/dP9PlNjFc). ## Related - [Sci-fi interface](https://zumerlab.com/orbit/examples/scifi.html) - [Nested systems demo](https://zumerlab.com/orbit/demos/08-nested-orbits.html) - [Gravity spot](https://zumerlab.com/orbit/reference/gravity-spot.html): a satellite is a universe too --- Source: https://zumerlab.com/orbit/examples/chemical_structures.html # Chemical structure Caffeine, drawn as two systems side by side: a six-membered ring and a five-membered one that share a bond. Each ring of bonds is a polygon of `.side` elements, each atom a satellite. ```html

      C8H10N4O2Caffeine

      ``` ```css body { background: var(--bg); } .stage { gap: 4px; } .stage > .bigbang { width: min(100%, 100vh - 90px); } .molecule { color: #c3ccd6; } .vector, .side { height: 2px; } /* An atom hides the bonds behind its letter with the page color. */ .atom { background: var(--bg); font-size: 15px; font-weight: 600; color: var(--text); } .atom sub { font-size: .7em; } .n { color: var(--blue); } .o { color: var(--coral); } .readout { margin: 0; } .readout strong { font-size: 20px; letter-spacing: 0; } ``` ```js // Six sides make the hexagon. The pentagon is drawn twice: once whole, once with // every other side left out, slightly inside, for the double bonds. document.querySelector('.molecule .bonds').innerHTML = '
      '.repeat(6); document.querySelectorAll('.molecule .bonds')[1].innerHTML = '
      '.repeat(5); document.querySelector('.molecule .double').innerHTML = ['hidden', '', 'hidden', '', 'hidden'].map((c) => `
      `).join(''); ``` ## How it's built - Sides connect neighboring places on a ring, so six [`.side`](https://zumerlab.com/orbit/reference/side.html) elements make a hexagon and five a pentagon. The double bonds of the pentagon are a second, smaller pentagon with every other side hidden. - Each ring of atoms is its own `.gravity-spot` inside a satellite as wide as the universe, so both use lanes of the same size. See [gravity spot](https://zumerlab.com/orbit/reference/gravity-spot.html). - The hexagon's satellite sits two lanes left of the center, the pentagon's on `orbit-3 shrink-35` to the right: the distance at which the two rings share a bond. `--o-force: 150cqmin` makes the reach larger than the universe, so the molecule fills the frame. - Atoms are satellites at fixed angles. Their background is the page color, which hides the bonds behind each letter. - Single bonds to the outside are [vectors](https://zumerlab.com/orbit/reference/vector.html) at `angle-N`; methyls and oxygens ride on `orbit-6`, spread over half the circle with `range-180 ccw fit-range`. Benzene, pyridine or furan use the same parts with a different number of sides. ## Related - [Geometric square mandala](https://zumerlab.com/orbit/examples/mandalas/geometric-square-mandala.html): systems inside satellites - [Hexagonal flower](https://zumerlab.com/orbit/examples/mandalas/index.html#starters): a hexagon of sides - [Sides](https://zumerlab.com/orbit/reference/side.html) --- Source: https://zumerlab.com/orbit/examples/gauges/ # Gauges A gauge is an arc, some labels and something that points. In Orbit the arc is a ring of `` bands, the labels are curved text or satellites, and the needle is a `.vector` on a ring turned with `rotate`. All of it is HTML you can style and inspect. - [180° gauge](https://zumerlab.com/orbit/examples/gauges/180-degree-gauge.html): Five named zones and a needle on a half circle. - [240° gauge](https://zumerlab.com/orbit/examples/gauges/240-degree-gauge.html): A stepped speedometer: bands light up with the speed. - [Fuel gauge](https://zumerlab.com/orbit/examples/gauges/fuel-gauge.html): A 90° dial with E, F and a reserve, in a box cut to fit. For a gauge with ticks, ranges and a value API already wired, see the [Orbit Kit gauge](https://zumerlab.com/orbit/kit/gauge.html). ## Related - [Progress rings](https://zumerlab.com/orbit/examples/progress/index.html): a value without a needle - [Knobs](https://zumerlab.com/orbit/examples/knobs/index.html): gauges you can turn - [Arcs](https://zumerlab.com/orbit/reference/arc.html) and [vectors](https://zumerlab.com/orbit/reference/vector.html) --- Source: https://zumerlab.com/orbit/examples/progress/ # Progress rings A circular progress bar in Orbit is one element, ``, inside a ring. Its color, track, ends and thickness are CSS. These four cover the usual patterns. - [Progress with background](https://zumerlab.com/orbit/examples/progress/circular-progress-with-background.html): A fill over its track, number in the middle. - [Dashed progress](https://zumerlab.com/orbit/examples/progress/dashed-circular-progress-bar.html): Ten segments for progress counted in steps. - [Progress with handle](https://zumerlab.com/orbit/examples/progress/circular-progress-with-handle.html): A circular slider you can drag. - [Stacked progress](https://zumerlab.com/orbit/examples/progress/stacked-circular-progress-bars.html): Three live metrics, labeled in the open quarter. ## Related - [Progress](https://zumerlab.com/orbit/reference/progress.html) - [Orbit Kit rings](https://zumerlab.com/orbit/kit/rings.html) - [Gauges](https://zumerlab.com/orbit/examples/gauges/index.html): when you want a needle - [Fitness and health apps](https://zumerlab.com/orbit/use-cases/fitness-and-health-apps.html) --- Source: https://zumerlab.com/orbit/examples/knobs/ # Knobs and menus Controls that live on a circle. A knob is a body, ticks and an indicator that turns; a pie menu is a ring of clickable arcs. Use a continuous arc for values like gain, LEDs for discrete steps, and a pie menu when the choices fit around a point. - [Knob with progress bar](https://zumerlab.com/orbit/examples/knobs/knob-with-progress-bar.html): A gain knob whose arc grows from the center. - [LED knob](https://zumerlab.com/orbit/examples/knobs/led-knob.html): Eleven LEDs and a knob that snaps between them. - [Pie menu](https://zumerlab.com/orbit/examples/piemenu.html): Wedges that open their options on an outer ring. All three work with the pointer and the keyboard. [Build a knob](https://zumerlab.com/orbit/guide.html) explains that part step by step, and the [Kit knob](https://zumerlab.com/orbit/kit/knob.html) and [Kit menu](https://zumerlab.com/orbit/kit/menu.html) come with it built in. ## Related - [Progress rings](https://zumerlab.com/orbit/examples/progress/index.html) - [Audio app interfaces](https://zumerlab.com/orbit/use-cases/audio-app-ui.html) --- Source: https://zumerlab.com/orbit/examples/charts/ # Charts Pies, donuts and sunbursts are arcs with values. Put `` elements in a ring and they follow each other around it, each taking its share. Orbit draws the paths; you bring the data. - [Donut chart](https://zumerlab.com/orbit/examples/charts/donut-chart.html): Five arcs with values, and two weeks to switch. - [Pie chart](https://zumerlab.com/orbit/examples/charts/pie-chart.html): Slices that slide out on hover and focus. - [Sunburst chart](https://zumerlab.com/orbit/examples/charts/sunburst-chart.html): Two levels of nested data that line up on their own. - A donut leaves room in the middle for a total. - A pie is denser, and pairs well with a legend outside it. - A sunburst shows nested categories, one ring per level. ## Related - [Arcs](https://zumerlab.com/orbit/reference/arc.html) - [Orbit Kit donut](https://zumerlab.com/orbit/kit/donut.html) - [Dashboards](https://zumerlab.com/orbit/use-cases/dashboards.html) --- Source: https://zumerlab.com/orbit/examples/watches/ # Watches and clocks A watch face is many things around one center: a bezel, sixty ticks, twelve numbers, hands and sometimes whole sub-dials. In Orbit the ticks are vectors, the numbers are satellites, each hand is a vector on a ring that turns, and a sub-dial is a satellite with its own system. All of them tell the real time. - [Minimalist analog clock](https://zumerlab.com/orbit/examples/watches/minimalist-analog-clock.html): Ticks, numbers and three hands, on the real time. - [Chronograph](https://zumerlab.com/orbit/examples/watches/chronograph-watch-face.html): Three sub-dials, each its own system, and a working stopwatch. - [Roman numeral clock](https://zumerlab.com/orbit/examples/watches/roman-numeral-clock-face.html): Numerals along the radius, with spin-lock and flip. - [Smartwatch face](https://zumerlab.com/orbit/examples/watches/smartwatch-face.html): Time and date as curved text along the bezel. - [Circular timer](https://zumerlab.com/orbit/examples/circular_time.html): Turn the wheel to set it; three rings share one angle. ## Related - [Orbit Kit clock](https://zumerlab.com/orbit/kit/clock.html) - [Calendars](https://zumerlab.com/orbit/examples/calendar/index.html) - [Vectors](https://zumerlab.com/orbit/reference/vector.html) and [satellites](https://zumerlab.com/orbit/reference/satellite.html) --- Source: https://zumerlab.com/orbit/use-cases/dashboards.html # Dashboards A single headline number, a part of a whole, progress toward a goal: these read best on a circle. Orbit builds gauges, rings and donuts from HTML and CSS, so they take your design tokens, dark mode and transitions like the rest of the page, and you can inspect them in DevTools. ## Where it fits - A design system that needs its own gauge, donut and progress components instead of a chart library's defaults. - Small pages where a chart library would be most of the JavaScript. - Values that change: set `value` on an `` or `` and it redraws on the next frame. ## Where it does not - Time series and multi-series charts. Use a charting library. - Thousands of data points. Canvas handles that better than the DOM. ## Start from - [Speedometer](https://zumerlab.com/orbit/demos/04-arrow-gauge.html): Ticks, zones and a needle on one 270° dial. - [240° gauge](https://zumerlab.com/orbit/examples/gauges/240-degree-gauge.html): A headline KPI on a dial. - [Stacked progress](https://zumerlab.com/orbit/examples/progress/stacked-circular-progress-bars.html): Three related metrics around one center. - [Donut chart](https://zumerlab.com/orbit/examples/charts/donut-chart.html): A breakdown with room for a total. - [Car dashboard](https://zumerlab.com/orbit/examples/dashboard.html): A whole panel built from nested systems. A monitoring view could pair a 240° gauge for CPU with three small rings for memory, disk and network, and a donut for traffic by region. Each one is a `.bigbang` as big as its card. The [Orbit Kit](https://zumerlab.com/orbit/kit/index.html) has gauge, donut and rings components ready to use. ## Related - [Audio app interfaces](https://zumerlab.com/orbit/use-cases/audio-app-ui.html) - [Fitness and health apps](https://zumerlab.com/orbit/use-cases/fitness-and-health-apps.html) - [IoT and hardware interfaces](https://zumerlab.com/orbit/use-cases/iot-and-hardware-ui.html) - [Sci-fi and game interfaces](https://zumerlab.com/orbit/use-cases/sci-fi-game-uis.html) - [All examples](https://zumerlab.com/orbit/examples/index.html) --- Source: https://zumerlab.com/orbit/use-cases/iot-and-hardware-ui.html # IoT and hardware interfaces Devices are full of dials: thermostats, fan speeds, fuel and battery levels, valve positions. The quantities are continuous and bounded, so a radial control matches how people think of the thing. Orbit gives you those controls as HTML that any framework, or none, can update from a WebSocket or MQTT message. ## Where it fits - Smart-home panels and cards that should look the same across platforms. - Companion apps for devices with a knob or a gauge of their own: e-bikes, printers, brewing controllers. - Device screens prototyped in HTML, then shipped in a web view. - Live telemetry: one attribute or custom property per message, redrawn on the next frame. ## Where it does not - Old embedded browsers. Orbit needs container query units and CSS `sin()` and `cos()`. - Illustrated, branded controls. Draw the illustration and put an Orbit gauge on top for the values. ## Start from - [Half gauge](https://zumerlab.com/orbit/demos/06-semicircle.html): A battery in 20 cells on a half circle. - [Fuel gauge](https://zumerlab.com/orbit/examples/gauges/fuel-gauge.html): A compact level instrument. - [Knob with progress bar](https://zumerlab.com/orbit/examples/knobs/knob-with-progress-bar.html): A setpoint control. - [Layered status rings](https://zumerlab.com/orbit/examples/layered-status-rings.html): Several channels in one object; alerts come to the front. - [Progress with background](https://zumerlab.com/orbit/examples/progress/circular-progress-with-background.html): Battery, fill level, filter life. A thermostat screen could be a large temperature gauge, a knob for the setpoint and two small rings for humidity and filter life. The [Kit instrument](https://zumerlab.com/orbit/kit/instrument.html) and [gauge](https://zumerlab.com/orbit/kit/gauge.html) are good starting points. ## Related - [Audio app interfaces](https://zumerlab.com/orbit/use-cases/audio-app-ui.html) - [Dashboards](https://zumerlab.com/orbit/use-cases/dashboards.html) - [Fitness and health apps](https://zumerlab.com/orbit/use-cases/fitness-and-health-apps.html) - [Sci-fi and game interfaces](https://zumerlab.com/orbit/use-cases/sci-fi-game-uis.html) - [All examples](https://zumerlab.com/orbit/examples/index.html) --- Source: https://zumerlab.com/orbit/use-cases/fitness-and-health-apps.html # Fitness and health apps Activity rings made the circle the way to show a daily goal: you see how close you are without reading a number. With Orbit, rings, gauges and watch faces are a handful of HTML elements, updated by setting a value. ## Where it fits - Goal rings for steps, calories, exercise, water or sleep. - Live readouts during a workout, like heart rate from a Bluetooth sensor. - Summary screens with several radial figures. - Web and PWA versions of an app, without a chart library. Give each ring `role="meter"` or `progressbar` and its value, and screen readers announce it. ## Where it does not - Trends over days, like weight or sleep history. Use a charting library. - Route maps and heatmaps. ## Start from - [Progress rings](https://zumerlab.com/orbit/demos/01-minimal-gauge.html): Three goals with rounded ends. - [Stacked progress](https://zumerlab.com/orbit/examples/progress/stacked-circular-progress-bars.html): Three goals around one center. - [180° gauge](https://zumerlab.com/orbit/examples/gauges/180-degree-gauge.html): A zone readout, like heart rate. - [Smartwatch face](https://zumerlab.com/orbit/examples/watches/smartwatch-face.html): Live time with complications. A daily summary could be three rings at the top, a heart-rate gauge under them and a row of small dials for water, sleep and steps. The [Kit rings](https://zumerlab.com/orbit/kit/rings.html) component does the first part. ## Related - [Audio app interfaces](https://zumerlab.com/orbit/use-cases/audio-app-ui.html) - [Dashboards](https://zumerlab.com/orbit/use-cases/dashboards.html) - [IoT and hardware interfaces](https://zumerlab.com/orbit/use-cases/iot-and-hardware-ui.html) - [Sci-fi and game interfaces](https://zumerlab.com/orbit/use-cases/sci-fi-game-uis.html) - [All examples](https://zumerlab.com/orbit/examples/index.html) --- Source: https://zumerlab.com/orbit/use-cases/audio-app-ui.html # Audio app interfaces Audio software never gave up the knob. It maps a wide range to a small gesture, and your hand remembers where it was. Orbit gives you the knob, its ticks, its arc and the meters around it as HTML, so the only script you write maps a drag to a value. ## Where it fits - Web audio tools and plugins built on Web Audio or AudioWorklet. - Synth and effect layouts you want to try with users before building them natively. - Companion apps for controllers, interfaces and MIDI gear. - Controls that need to be accessible: a knob is a DOM element, so it can be a `role="slider"` with `aria-valuenow` and arrow keys. ## Where it does not - A large mixer with hundreds of meters moving at audio rate. Draw those on a canvas. - Photographic knobs. Use an image for the texture and Orbit for the value around it. ## Start from - [Knob](https://zumerlab.com/orbit/demos/05-knob.html): Drag, scroll or use the keys. - [Knob with progress bar](https://zumerlab.com/orbit/examples/knobs/knob-with-progress-bar.html): A continuous value with ticks and an arc. - [LED knob](https://zumerlab.com/orbit/examples/knobs/led-knob.html): Stepped values, one light per step. - [240° gauge](https://zumerlab.com/orbit/examples/gauges/240-degree-gauge.html): A level meter with zones. An EQ, for example, is four knobs in a row with their values in the middle, an input and an output meter, and a ring around the bypass button. [Build a knob](https://zumerlab.com/orbit/guide.html) covers drag, wheel and keyboard. The [Kit knob](https://zumerlab.com/orbit/kit/knob.html) has all of it ready. ## Related - [Dashboards](https://zumerlab.com/orbit/use-cases/dashboards.html) - [Fitness and health apps](https://zumerlab.com/orbit/use-cases/fitness-and-health-apps.html) - [IoT and hardware interfaces](https://zumerlab.com/orbit/use-cases/iot-and-hardware-ui.html) - [Sci-fi and game interfaces](https://zumerlab.com/orbit/use-cases/sci-fi-game-uis.html) - [All examples](https://zumerlab.com/orbit/examples/index.html) --- Source: https://zumerlab.com/orbit/use-cases/sci-fi-game-uis.html # Sci-fi and game interfaces Sci-fi interfaces live on circles: concentric rings, turning bezels, curved labels, geometry in the background. Orbit builds those from HTML and CSS, and animates them with ordinary keyframes on `rotate` and `opacity`, which the browser composites without your script. ## Where it fits - HUDs and menus over a web game, as a DOM layer on top of the canvas. - Game interfaces prototyped in HTML before they move to an engine. - Game websites that should speak the game's visual language. - Decorative backgrounds: mandalas, turning bezels, rings behind content. ## Where it does not - The game itself. Orbit is for the interface around it. - Pixel art. Orbit draws smooth geometry. ## Start from - [Radar](https://zumerlab.com/orbit/demos/10-radar.html): A sweeping beam and contacts that flash in time. - [Sci-fi interface](https://zumerlab.com/orbit/examples/scifi.html): Arcs and dashes at different depths, tilting with the pointer. - [Abstract orbital map](https://zumerlab.com/orbit/examples/abstract_map.html): Planets that are systems of their own, each ring turning. - [Pie menu](https://zumerlab.com/orbit/examples/piemenu.html): A radial menu with submenus. - [Radial symmetry mandala](https://zumerlab.com/orbit/examples/mandalas/radial-symmetry-mandala.html): Geometry for backgrounds and emblems. A cockpit could be a slow mandala behind everything, gauges for shields and hull, a cluster of sub-dials for cooldowns and the target's name in curved text around the bezel. For layers that move in z, see [depth layers](https://zumerlab.com/orbit/craft/depth.html). ## Related - [Audio app interfaces](https://zumerlab.com/orbit/use-cases/audio-app-ui.html) - [Dashboards](https://zumerlab.com/orbit/use-cases/dashboards.html) - [Fitness and health apps](https://zumerlab.com/orbit/use-cases/fitness-and-health-apps.html) - [IoT and hardware interfaces](https://zumerlab.com/orbit/use-cases/iot-and-hardware-ui.html) - [All examples](https://zumerlab.com/orbit/examples/index.html)