Documentation

Everything needed to use the system.

Install, tokens, principles and the component reference — one page, so ⌘F finds anything.

v0.1Next.js 16No runtime dependencies

Overview

assetis/ui is a private design system for Next.js. Near-monochrome surfaces carry the structure, one accent hue carries meaning, and colour appears nowhere else except four status indicators.

It ships TypeScript source rather than a build. Next compiles it with your app via transpilePackages, which means no bundler config, no release pipeline, and readable stack traces.

Runtime dependencies0
Components80+
Design tokens92
Themeslight · dark · auto

The rest of this site is the system demonstrating itself: Foundations, Components, Patterns and the app shell.

Installation

The package lives at the repository root, so a plain git dependency works. Always pin a tag rather than a branch — that keeps updates deliberate.

1 — add the dependency
npm i git+https://git.jbarszcz.pl/ASSETIS/ui.git#v0.1.0

# or over SSH
npm i git+ssh://[email protected]/ASSETIS/ui.git#v0.1.0
2 — transpile the package
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  transpilePackages: ["@assetis/ui"],
};

export default nextConfig;
3 — styles, fonts and providers
// app/layout.tsx
import { AssetisFonts, ThemeProvider, ThemeScript } from "@assetis/ui";
import "@assetis/ui/styles.css";

export default function RootLayout({ children }) {
  return (
<html lang="en" suppressHydrationWarning>
  <head>
    <AssetisFonts />
    {/* applies the stored theme before first paint */}
    <ThemeScript />
  </head>
  <body>
    <ThemeProvider defaultAccent="indigo">{children}</ThemeProvider>
  </body>
</html>
  );
}

Requirements

  • Next.js 14+ with the App Router, React 18+.
  • In a TypeScript project, @types/react and @types/react-dom — the package ships .tsx, so your tsc checks it too.
Fonts come from Google Fonts
AssetisFonts loads Manrope, Roboto Mono and Material Symbols. To self-host instead, skip it and point --a-font-sans, --a-font-mono and --a-font-icon at whatever you load.

Updating

Move the pinned tag, then update. Nothing changes until you decide it does.

in the consuming project
# edit package.json → "@assetis/ui": "git+https://…/ui.git#v0.2.0"
npm update @assetis/ui

See Releasing for cutting the tag on the design system side.

Principles

Eight rules. Everything in the system follows from them.

Neutrals are chroma 0
Literally grey. Colour only appears in the accent and the four indicators, which is what makes a status readable at a glance.
No 1px borders
Buttons, inputs, badges and cards are shaped by a three-step neutral fill ladder. Hairlines survive only where two regions genuinely meet — table rows, dividers, the index grid.
One accent, two numbers
A hue and a chroma multiplier drive every accent token, focus ring, badge and gradient field.
One icon set
Material Symbols Outlined at weight 200, everywhere.
Outbound links are marked
Links that leave the site get a trailing arrow and safe rel defaults. Navigation links never do.
Light and dark from one definition
Tokens use CSS light-dark(), so a theme switch flips color-scheme instead of redeclaring the palette.
Motion is opacity only
Nothing slides, scales or nudges. Transform-based animation renders text soft in some browsers, so the system does without it.
Responsive is not optional
Overlays become sheets under 620px, the sidebar becomes a drawer under 900px, tables stack into cards under 640px — even where that bends a rule above.

Design tokens

Every value the system uses, declared once in src/styles/tokens.css. This table is generated from that file, so it cannot drift.

fonts

--a-font-sans"Manrope", ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif
--a-font-mono"Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
--a-font-icon"Material Symbols Outlined"
--a-icon-weight200

neutrals — chroma 0, no exceptions

component fills — these replace 1px borders on buttons, chips, inputs

--a-bglight-dark(oklch(0.994 0 0), oklch(0.166 0 0))
--a-bg-2light-dark(oklch(0.974 0 0), oklch(0.196 0 0))
--a-surfacelight-dark(oklch(1 0 0), oklch(0.213 0 0))
--a-surface-2light-dark(oklch(1 0 0), oklch(0.247 0 0))
--a-sunkenlight-dark(oklch(0.962 0 0), oklch(0.184 0 0))
--a-filllight-dark(oklch(0.958 0 0), oklch(0.256 0 0))
--a-fill-2light-dark(oklch(0.930 0 0), oklch(0.302 0 0))
--a-fill-3light-dark(oklch(0.900 0 0), oklch(0.344 0 0))
--a-linelight-dark(oklch(0.914 0 0), oklch(0.286 0 0))
--a-line-2light-dark(oklch(0.848 0 0), oklch(0.372 0 0))
--a-textlight-dark(oklch(0.215 0 0), oklch(0.966 0 0))
--a-text-2light-dark(oklch(0.472 0 0), oklch(0.752 0 0))
--a-text-3light-dark(oklch(0.630 0 0), oklch(0.602 0 0))
--a-text-invlight-dark(oklch(0.994 0 0), oklch(0.166 0 0))
--a-solidlight-dark(oklch(0.215 0 0), oklch(0.966 0 0))
--a-solid-fglight-dark(oklch(0.994 0 0), oklch(0.166 0 0))
--a-overlaylight-dark(oklch(0.215 0 0 / 0.28), oklch(0.100 0 0 / 0.62))
--a-bg-a100light-dark(oklch(0.994 0 0 / 1), oklch(0.166 0 0 / 1))
--a-bg-a92light-dark(oklch(0.994 0 0 / 0.92), oklch(0.166 0 0 / 0.92))
--a-bg-a60light-dark(oklch(0.994 0 0 / 0.60), oklch(0.166 0 0 / 0.60))
--a-bg-a0light-dark(oklch(0.994 0 0 / 0), oklch(0.166 0 0 / 0))

accent — derived, never hand-picked

--a-accentlight-dark(oklch(0.556 calc(0.204 * var(--a-accent-c)) var(--a-accent-h)), oklch(0.680 calc(0.176 * var(--a-accent-c)) var(--a-accent-h)))
--a-accent-hoverlight-dark(oklch(0.498 calc(0.196 * var(--a-accent-c)) var(--a-accent-h)), oklch(0.735 calc(0.156 * var(--a-accent-c)) var(--a-accent-h)))
--a-accent-activelight-dark(oklch(0.448 calc(0.184 * var(--a-accent-c)) var(--a-accent-h)), oklch(0.786 calc(0.136 * var(--a-accent-c)) var(--a-accent-h)))
--a-accent-fglight-dark(oklch(0.992 calc(0.008 * var(--a-accent-c)) var(--a-accent-h)), oklch(0.166 calc(0.024 * var(--a-accent-c)) var(--a-accent-h)))
--a-accent-softlight-dark(oklch(0.960 calc(0.036 * var(--a-accent-c)) var(--a-accent-h)), oklch(0.286 calc(0.064 * var(--a-accent-c)) var(--a-accent-h)))
--a-accent-soft-2light-dark(oklch(0.922 calc(0.062 * var(--a-accent-c)) var(--a-accent-h)), oklch(0.342 calc(0.086 * var(--a-accent-c)) var(--a-accent-h)))
--a-accent-textlight-dark(oklch(0.470 calc(0.192 * var(--a-accent-c)) var(--a-accent-h)), oklch(0.812 calc(0.124 * var(--a-accent-c)) var(--a-accent-h)))
--a-accent-linelight-dark(oklch(0.872 calc(0.078 * var(--a-accent-c)) var(--a-accent-h)), oklch(0.412 calc(0.104 * var(--a-accent-c)) var(--a-accent-h)))
--a-ringlight-dark(oklch(0.556 calc(0.204 * var(--a-accent-c)) var(--a-accent-h) / 0.32), oklch(0.680 calc(0.176 * var(--a-accent-c)) var(--a-accent-h) / 0.40))

indicators — the only other place chroma is allowed

--a-oklight-dark(oklch(0.548 0.118 150), oklch(0.712 0.130 150))
--a-ok-softlight-dark(oklch(0.960 0.030 150), oklch(0.278 0.048 150))
--a-ok-textlight-dark(oklch(0.442 0.108 150), oklch(0.818 0.100 150))
--a-warnlight-dark(oklch(0.700 0.140 72), oklch(0.786 0.134 78))
--a-warn-softlight-dark(oklch(0.966 0.040 75), oklch(0.290 0.050 70))
--a-warn-textlight-dark(oklch(0.478 0.110 62), oklch(0.858 0.100 82))
--a-errlight-dark(oklch(0.556 0.174 27), oklch(0.684 0.170 27))
--a-err-softlight-dark(oklch(0.960 0.028 27), oklch(0.286 0.060 27))
--a-err-textlight-dark(oklch(0.474 0.162 27), oklch(0.828 0.100 27))
--a-infolight-dark(oklch(0.556 0.154 250), oklch(0.670 0.172 250))
--a-info-softlight-dark(oklch(0.960 0.030 250), oklch(0.292 0.066 250))
--a-info-textlight-dark(oklch(0.458 0.170 250), oklch(0.822 0.112 250))

radii

--a-r-xs4px
--a-r-sm6px
--a-r-md9px
--a-r-lg14px
--a-r-xl20px
--a-r-2xl28px
--a-r-full999px

elevation — quiet, never a substitute for hierarchy

--a-sh-1light-dark(0 1px 2px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.4))

motion

--a-easecubic-bezier(0.22, 0.61, 0.36, 1)
--a-ease-iocubic-bezier(0.4, 0, 0.2, 1)
--a-t-fast110ms
--a-t-base180ms
--a-t-slow300ms

space

--a-s-14px; --a-s-2: 8px; --a-s-3: 12px; --a-s-4: 16px
--a-s-520px; --a-s-6: 24px; --a-s-8: 32px; --a-s-10: 40px
--a-s-1248px; --a-s-16: 64px; --a-s-20: 80px; --a-s-24: 96px

type scale — fluid, so mobile never has to be patched

--a-fs-displayclamp(2.35rem, 1.35rem + 4.6vw, 4.5rem)
--a-fs-h1clamp(1.85rem, 1.25rem + 2.6vw, 2.85rem)
--a-fs-h2clamp(1.4rem, 1.12rem + 1.2vw, 1.9rem)
--a-fs-h3clamp(1.12rem, 1.02rem + 0.45vw, 1.32rem)
--a-fs-h41rem
--a-fs-leadclamp(1rem, 0.95rem + 0.3vw, 1.16rem)
--a-fs-body0.925rem
--a-fs-sm0.84rem
--a-fs-xs0.78rem
--a-fs-2xs0.72rem

chrome

--a-topbar-h56px
--a-sidebar-w252px
--a-sidebar-rail-w60px
--a-content-max1140px
--a-prose-max68ch
--a-gutterclamp(16px, 4vw, 40px)

Accent system

The whole system is derived from two custom properties. Change them and every accent token, focus ring, badge, chart bar and gradient field follows.

the only two values that matter
:root {
  --a-accent-h: 265;   /* oklch hue, 0..360 */
  --a-accent-c: 1;     /* chroma multiplier: 0 = grey, 1 = default, 1.3 = vivid */
}

Three ways to set it

accent.ts
// 1. globally, in your own CSS
:root { --a-accent-h: 320; }

// 2. at runtime
import { setAccent } from "@assetis/ui";
setAccent("teal");              // a preset
setAccent({ h: 320, c: 1.1 });  // anything

// 3. scoped to one subtree
import { accentStyle } from "@assetis/ui";
<section style={accentStyle({ h: 150 })}>…</section>

Presets

Available as strings to setAccent / ThemeProvider defaultAccent, and as data-accent="teal" on any element.

indigotealmossclayrosevioletslate

Try it live on the Foundations page — the hue and chroma sliders re-tone the whole site as you drag.

Theming

Three states — light, dark and auto — so the control is a dropdown, not a toggle.ThemeScript applies the stored choice before first paint.

theme controls
<ThemeSelect />            // icon only, for nav chrome
<ThemeSelect showLabel />  // icon + current mode name

// read or set it anywhere
const { mode, resolved, setMode, accent, setAccent } = useTheme();
//     "system"  "dark"   fn        "indigo"  fn
PropTypeDefaultDescription
defaultMode"light" | "dark" | "system""system"Used until a stored preference is found.
defaultAccentAccent"indigo"Preset name or { h, c }.
storageKeystring | null"assetis-ui"localStorage prefix. null disables persistence.
Keep the storage keys in sync
ThemeScript and ThemeProvider must use the same storageKey, or the pre-paint script and the app will disagree.

Icons

Material Symbols Outlined at weight 200. The weight is a token, so it can be changed once for the whole system.

Icon
<Icon name="search" />
<Icon name="database" size={24} />
<Icon name="check_circle" filled />
PropTypeDefaultDescription
namestringMaterial Symbols ligature, e.g. arrow_outward.
sizenumber20Rendered size in px; also picks the optical size axis.
weight100–700200Overrides --a-icon-weight for one icon.
filledbooleanfalseSwitches the FILL axis on.
gradenumber0Emphasis axis, −25 to 200.

Browse names at Google Fonts icons.

Marketing nav

One floating pill, detached from the top edge, tightening on scroll. Below 900px the links collapse into a sheet hung beneath it. Wrap page content in .aui-marketing so it clears the pill.

MarketingNav
<MarketingNav
  linkAs={NextLink}
  brand={<><Mark /> <span>assetis/ui</span></>}
  links={[
    { label: "Docs", href: "/docs", active: pathname === "/docs" },
    { label: "Changelog", href: "https://…", external: true },
  ]}
  actions={<><ThemeSelect /><Button variant="solid" pill>Open the app</Button></>}
  mobileActions={<Button variant="solid" pill block>Open the app</Button>}
/>
PropTypeDefaultDescription
brandReactNodeWordmark and logo.
linksNavLink[]label, href, active, external.
actionsReactNodeRight-hand controls. Add .aui-pillnav__hide-sm to drop one on mobile.
mobileActionsReactNodeRendered at the bottom of the mobile sheet.
linkAsElementType"a"Pass next/link for client-side routing.
maxWidthnumber1080Pill width before it tightens on scroll.

App shell

A sidebar that collapses to an icon rail or hides entirely, plus a top bar with no border and no fill — a gradient veil and a masked blur, so page content dissolves as it scrolls underneath. Below 900px the sidebar becomes an overlay drawer and the toggle opens it.

AppShell
<AppShell defaultSidebar="expanded" collapseTo="rail">
  <AppSidebar
    linkAs={NextLink}
    brand={<><Mark /> <span>Acme Research</span></>}
    groups={[
      { items: [{ label: "Overview", icon: "dashboard", href: "/app", active: true }] },
      { title: "Workspace", items: [{ label: "Settings", icon: "settings", href: "/app/settings" }] },
    ]}
    footer={<SidebarUser name="Ada Karlsen" detail="Owner" />}
  />
  <AppMain>
    <AppTopBar actions={<ThemeSelect />}>
      <Breadcrumbs items={[{ label: "Acme", href: "/app" }, { label: "Overview" }]} />
    </AppTopBar>
    <AppContent wide>{children}</AppContent>
  </AppMain>
</AppShell>
PropTypeDefaultDescription
defaultSidebar"expanded" | "rail" | "hidden""expanded"Initial desktop state.
collapseTo"rail" | "hidden""rail"What the toggle collapses to on desktop.
storageKeystring | null"assetis-ui-sidebar"Persists the desktop state.

useAppShell() exposes sidebar, setSidebar, toggleSidebar, mobileNavOpen and isMobile for custom chrome.

Component index

Eighty-odd exports. Every one is demonstrated on the Components page.

Primitives
Icon · Link · Button · IconButton · ButtonGroup · Kbd · DividerButtons take variant (neutral, accent, solid, ghost, soft, danger) and size (sm, md, lg).
Forms
Field · Input · Textarea · Select · InputGroup · SearchInput · Checkbox · Radio · ChoiceCard · Switch · SliderWrap any control in Field to get a label, hint and error slot with the right ids.
Data display
Badge · StatusDot · Tag · Avatar · AvatarGroup · Card · Stat · Alert · Progress · Spinner · Skeleton · EmptyStateStat handles the up-is-good / up-is-bad distinction with upIsGood.
Structure
Container · Section · Hero · Grid · Bento · Feature · Prose · IndexGrid · MetaRow · Tabs · Segmented · AccordionContainer caps width at --a-content-max and applies the responsive gutter.
Overlays
Modal · Drawer · Menu · Tooltip · Portal · ToastProvider · CommandPaletteAll of them become sheets below 620px. Tooltips never appear on touch devices.
Editorial
Tile · TileGrid · GradientPanel · GradientText · Glow · Glass · CodeBlock · InlineCodeColour lives in GradientPanel and tile media. Everything else stays neutral.
Navigation
MarketingNav · MarketingFooter · AppShell · AppSidebar · AppTopBar · AppMain · AppContent · Breadcrumbs · SidebarUser · PaginationPass linkAs={NextLink} to any of them for client-side routing.
Theming
ThemeProvider · ThemeScript · ThemeSelect · ThemeToggle · AssetisFonts · AccentSwatchesThemeScript must render in <head> to avoid a flash of the wrong theme.

Tiles

The editorial unit: a media block on top, then category, title, excerpt and byline. The media carries the colour; everything under it stays neutral, so a grid of tiles reads as a list rather than a carnival.

Tile
<TileGrid min={260}>
  <Tile
    as={NextLink}
    href="/articles/latency"
    field="dusk"                 // or media={<img … />}
    featured                     // media beside the text from 760px up
    flag={<Badge tone="solid">Featured</Badge>}
    eyebrow={<>Engineering <span>·</span> 6 min</>}
    title="How we cut p95 latency by 38%"
    excerpt="Continuous batching, speculative decoding and region-aware routing."
    meta={<><Avatar name="Rui Mendes" size={22} />Rui Mendes · 14 Aug 2026</>}
  />
</TileGrid>
PropTypeDefaultDescription
fieldGradientFieldaccent · dusk · tide · ember · spectrum. Fills the media block.
mediaReactNodeYour own media instead of a gradient.
featuredbooleanfalseSpans the grid; media beside text from 760px.
compactbooleanfalseSquare thumb, for dense rails.
flagReactNodePinned to the top-left of the media.
aspectstring"16 / 10"CSS aspect-ratio for the media block.

See them in use under Patterns → Article selection.

Overlays & toasts

Modal, Drawer, Menu, Tooltip and CommandPalette all render through a portal, lock body scroll, close on Escape and on a click outside, and trap focus while open. Below 620px they become bottom sheets.

overlays
<Modal open={open} onClose={() => setOpen(false)} title="Delete project?"
  footer={<><Button variant="ghost">Cancel</Button><Button variant="danger">Delete</Button></>}>
  …
</Modal>

<Drawer open={open} onClose={close} side="right" title="Request detail">…</Drawer>

<Menu trigger={<IconButton icon="more_horiz" label="More" />} align="end" items={[
  { type: "label", label: "This project" },
  { label: "Duplicate", icon: "content_copy", shortcut: "⌘D", onSelect: dup },
  { type: "separator" },
  { label: "Delete", icon: "delete", danger: true, onSelect: del },
]} />

Toasts

ToastProvider
// once, near the root
<ToastProvider>{children}</ToastProvider>

// anywhere below it
const { toast } = useToast();
toast({ title: "Deploy queued", description: "eu-west-2", tone: "ok" });
toast({ title: "Failed", tone: "err", action: { label: "Retry", onClick: retry } });

Command palette

CommandPalette
const [open, setOpen] = useCommandPalette();   // binds ⌘K / Ctrl-K

<CommandPalette open={open} onOpenChange={setOpen} commands={[
  { id: "1", group: "Navigate", label: "Go to billing", icon: "receipt_long",
    onSelect: () => router.push("/app/billing") },
]} />

Data tables

DataTable handles sorting, selection and row clicks. Below 640px each row becomes a labelled card — readability beats table purity.

DataTable
<DataTable
  rows={rows}
  rowKey={(r) => r.id}
  selected={selected}
  onSelectedChange={setSelected}
  onRowClick={setDetail}
  minWidth={720}
  empty={<EmptyState icon="search_off" title="No requests match" />}
  columns={[
    { key: "route", header: "Route", sortValue: (r) => r.route },
    { key: "status", header: "Status", sortValue: (r) => r.status,
      cell: (r) => <Badge dot tone={r.status >= 400 ? "err" : "ok"}>{r.status}</Badge> },
    { key: "ms", header: "Duration", numeric: true, sortValue: (r) => r.ms },
  ]}
  footer={<><span>{selected.length} selected</span><Pagination … /></>}
/>
PropTypeDefaultDescription
columnsColumn<T>[]key, header, cell, sortValue, numeric, align, width.
rowKey(row, i) => stringStable identity for selection and React keys.
selected / onSelectedChangestring[] / fnOmit both to hide the checkbox column.
stackOnMobilebooleantrueCard-per-row below 640px.
minWidthnumber640Width before horizontal scrolling kicks in.

Hooks

PropTypeDefaultDescription
useTheme()ThemeContextmode, resolved, setMode, toggle, accent, setAccent.
useToast(){ toast, dismiss }Must be under a ToastProvider.
useAppShell()ShellContextSidebar state and the mobile drawer.
useCommandPalette()[open, setOpen]Binds ⌘K / Ctrl-K.
useIsMobile()booleanTrue at or below 900px — the app-shell breakpoint.
useMediaQuery(q)booleanSSR-safe media query subscription.
useScrolled(px)booleanTrue once the page scrolls past px.
useOnKey(key, fn)voidDocument-level key handler.
useClickOutside(fn)refAttach the ref to the element that should stay open.
useScrollLock(active)voidReference-counted body scroll lock.
useFocusTrap(active)refContains Tab within the element.
useControllable(v, d, cb)[value, set]Controlled or uncontrolled, one hook.

Responsive behaviour

The system has three breakpoints that matter. Everything else is fluid — clamp() handles type, gutters and section rhythm.

PropTypeDefaultDescription
≤ 620pxoverlaysModals, drawers and menus become bottom sheets. Modal footers stretch their buttons.
≤ 640pxtablesDataTable rows become labelled cards.
≤ 900pxchromeThe app sidebar becomes an overlay drawer; the marketing pill collapses its links into a sheet.
≥ 760pxtilesFeatured tiles put their media beside the text.
  • Inputs render at 16px on phones so iOS does not zoom on focus.
  • Touch targets: buttons gain an invisible 44px hit area under (pointer: coarse).
  • Breadcrumbs shed segments as space runs out, keeping only the current page.
  • Tooltips never appear on touch devices.
  • Safe-area insets are respected on the pill, the toaster and bottom sheets.

Recipes

A marketing page

app/(site)/layout.tsx
<MarketingNav linkAs={NextLink} brand={…} links={…} />
<div className="aui-marketing">{children}</div>
<MarketingFooter columns={…} />

Scoping an accent to one section

accentStyle
import { accentStyle } from "@assetis/ui";

<Section style={accentStyle({ h: 150, c: 0.9 })}>
  {/* buttons, rings and gradients in here are moss */}
</Section>

A gradient field as decoration

GradientPanel
<GradientPanel field="spectrum" radius="var(--a-r-2xl)" style={{ minHeight: 320 }}>
  <Eyebrow>Colour is a tool</Eyebrow>
  <h2>Fields belong on marketing surfaces.</h2>
</GradientPanel>

Fields: accent, dusk, tide, ember, spectrum, plus the subtle wash and mist. All of them follow the accent hue.

Overriding a token for one app

app/globals.css
@import "@assetis/ui/styles.css";

:root {
  --a-accent-h: 190;
  --a-r-lg: 10px;                       /* squarer cards */
  --a-font-sans: "Inter", system-ui, sans-serif;
}

Releasing

in the design system repo
npm install            # once — links the showcase to the package
npm run showcase       # http://localhost:3000
npm run typecheck      # checks the package source

npm run docs:tokens    # regenerate the token tables on this page
                       # (run from showcase/ after editing tokens.css)

npm version minor      # bumps package.json and tags the commit
git push --follow-tags

Consumers bump their pinned tag when they want the change. npm publish is blocked by a prepublishOnly guard, so the package can only ever be installed from git.

Repository layout

ui/
ui/
├── src/            the package — components, tokens, styles
│   ├── components/ one file per component group
│   ├── lib/        cx, accent helpers, hooks
│   └── styles/     tokens.css + six layers
├── showcase/       this site; consumes the package via file:..
└── package.json    the repo root IS the package

Changelog

v0.1
current
  • Initial system: tokens, 80+ components, light/dark/auto.
  • Floating pill marketing nav; app shell with a borderless fading top bar.
  • Accent derived from a hue and a chroma multiplier.
  • Material Symbols at weight 200 throughout.
  • Editorial tiles and five hue-linked gradient fields.
  • Motion reduced to opacity only.