Layout and responsive
Layout tokens set the widths where Nuton's layouts change, the order in which surfaces stack, and the focus ring. Use them for page structure, overlays and keyboard focus; use spacing tokens for the space inside a layout.
Beta. Breakpoints, z-index and focus-ring width and offset are code-only by design (src/code): Figma variables can't express them. The focus-ring colour is interim (src/interim) and isn't applied to components until C5 is approved. Blocks Stable: C5 approval, the bottom-nav item set (D23), tablet and three-column collapse designs in Figma (D23 backlog), and a reflow check at 320px.
Overview
Three small sets of tokens. Breakpoints decide when a layout rearranges, the z scale decides which surface wins when two overlap, and the focus ring makes the keyboard position visible on every surface.
smfrom 640pxmdfrom 768pxlgfrom 1024pxxlfrom 1280px2xlfrom 1536px
sm is the base layout.tooltip80toast70popover60modal50overlay40sticky20raised10base0
Tokens
Generated from @nuton/tokens. Code-only tokens live in Git only and never as Figma variables; a check fails if a name exists in both places.
Breakpoints
| Token | Value | Source |
|---|---|---|
--nu-breakpoint-sm | 640px | Code-only |
--nu-breakpoint-md | 768px | Code-only |
--nu-breakpoint-lg | 1024px | Code-only |
--nu-breakpoint-xl | 1280px | Code-only |
--nu-breakpoint-2xl | 1536px | Code-only |
The same values as Tailwind's defaults, which the teacher app and marketing site already use. Figma has desktop (1440) and iPhone (393) frames and no tablet frames (D23 backlog). CSS can't read a variable inside @media; see Usage.
Content widths
| Token | Value | Source |
|---|---|---|
--nu-measure-reading | 46rem | Code-only |
--nu-measure-content | 52rem | Code-only |
--nu-measure-page | 66rem | Code-only |
The widths a screen's content keeps on a large window, so it is neither a phone stretched across a tablet nor a narrow column lost in the middle. measure-reading is about 70 characters for lessons and long text; measure-content is for a focused start screen or form; measure-page is for lists, grids and settings. A screen centres its content at one of these and lets columns follow the room inside it.
Layers (z-index)
| Token | Value | Source |
|---|---|---|
--nu-z-base | 0 | Code-only |
--nu-z-raised | 10 | Code-only |
--nu-z-sticky | 20 | Code-only |
--nu-z-overlay | 40 | Code-only |
--nu-z-modal | 50 | Code-only |
--nu-z-popover | 60 | Code-only |
--nu-z-toast | 70 | Code-only |
--nu-z-tooltip | 80 | Code-only |
Popover sits above modal on purpose: a menu or combobox opened from inside a dialog must appear over it.
| Layer | Use it for |
|---|---|
--nu-z-tooltip | Always on top. No tooltip component yet. |
--nu-z-toast | Notifications and this site’s skip link. No toast component yet. |
--nu-z-popover | Menus, listboxes and comboboxes (Menu.css), so a menu inside a dialog still opens above it. |
--nu-z-modal | Modal dialogs: the CommandMenu dialog. |
--nu-z-overlay | Scrims and drawers: the CommandMenu scrim, this site’s mobile sidebar. |
--nu-z-sticky | Sticky headers. This site’s header uses it. |
--nu-z-raised | Reserved for content lifted within a card or list. Not used yet. |
--nu-z-base | Normal document flow. |
Focus ring
| Token | Value | Source |
|---|---|---|
--nu-focus-ring-width | 2px | Code-only |
--nu-focus-ring-offset | 2px | Code-only |
| Preview | Token | Value | Source |
|---|---|---|---|
--nu-focus-ring-color | → state-primary-base#3559e9 | Interim |
Interim (C5): an alias of --nu-state-primary-base. Figma's focus effects (buttons/*-focus) measure about 1.1:1 against white; this colour measures 5.59:1. It's used by this site's own chrome, and moves to Figma once the DS Owner approves it.
Usage
Load @nuton/tokens/tokens.css once. Use the CSS variables for layers and focus, and the breakpoint values through Tailwind or JavaScript, because a media query can't read a CSS variable.
/* Layers: choose by role, never by number. */
.lesson-header { position: sticky; inset-block-start: 0; z-index: var(--nu-z-sticky); }
.tutor-sheet { position: fixed; z-index: var(--nu-z-overlay); }
/* Focus: outline (not box-shadow) so it survives forced-colours mode. */
.lesson-card:focus-visible {
outline: var(--nu-focus-ring-width) solid var(--nu-focus-ring-color);
outline-offset: var(--nu-focus-ring-offset);
}
/* Breakpoints: var() doesn't work in @media. Copy the value from
--nu-breakpoint-lg (1024px) and name the token beside it. */
@media (min-width: 1024px) { /* --nu-breakpoint-lg */
.lesson { grid-template-columns: 16rem minmax(0, 1fr) 22rem; }
}/* app.css */
@import 'tailwindcss';
@import '@nuton/tokens/tokens.css';
@import '@nuton/tokens/tailwind-v4.css'; /* sets --breakpoint-sm … 2xl */
// Breakpoint variants: sm: md: lg: xl: 2xl:
<div className="grid lg:grid-cols-[16rem_minmax(0,1fr)_22rem]">…</div>
// Focus colour: outline-focus-ring-color / ring-focus-ring-color
<a className="focus-visible:outline-focus-ring-color">…</a>
// The v4 theme has no z-index names. Reference the variable:
<header className="sticky top-0 z-(--nu-z-sticky)">…</header>// tailwind.config.js
module.exports = { presets: [require('@nuton/tokens/tailwind-v3-preset')] }
// Screens: sm md lg xl 2xl
// z-index: z-base z-raised z-sticky z-overlay z-modal z-popover z-toast z-tooltip
<header className="sticky top-0 z-sticky">…</header>
<div className="fixed inset-0 z-overlay bg-overlay-gray" />
// Focus colour: outline-focus-ring-color / ring-focus-ring-color
<a className="focus-visible:outline-focus-ring-color">…</a>import { tokens } from '@nuton/tokens'
// '1024px', read from the token, never retyped.
const wide = window.matchMedia(`(min-width: ${tokens['breakpoint-lg'].value})`)
wide.addEventListener('change', (e) => setTutorAsSheet(!e.matches))- Write mobile first: the base styles are the narrow layout, and each breakpoint adds to it with
min-width. - Components that live inside panels (the tutor, a sidebar) respond to their container with container queries, not to the window. A tutor panel is narrow on a wide screen.
- Pick a layer by what the surface is: sticky, overlay, modal, popover, toast, tooltip. Never write
z-index: 9999or bump a number to win a fight; fix the stacking context instead. - Use logical properties (
inset-inline-start,padding-inline) so layouts can mirror for right-to-left languages later. - Leave focus on
.nu-*components alone. They draw their own indicator; the focus-ring tokens are for your own interactive elements.
Tailwind v4 also gets bg-overlay-gray and the other colours from tailwind-v4.css; there are no Tailwind names for the focus-ring width and offset in either version, so use the CSS variables.
In use
Structured lesson (D23)
From lg up, the lesson shows contents, lesson and tutor side by side. Narrower, the tutor becomes an overlay sheet and the contents a drawer, both on --nu-z-overlay. D23 approves this collapse; the exact switch point (around md–lg) is validated per layout. This site switches its own sidebar to a drawer at lg.
1024px and wider: contents, lesson and tutor sit side by side.
const wide = useMediaQuery(`(min-width: ${tokens['breakpoint-lg'].value})`)
return wide ? (
<div className="lesson"> {/* three columns */}
<nav aria-label="Lesson contents"><LessonContents /></nav>
<article><Lesson /></article>
<aside aria-label="Tutor"><TutorPanel /></aside>
</div>
) : (
<>
<Button appearance="ghost" leadingIcon={<RiBookOpenLine />} aria-expanded={drawer} onClick={openDrawer}>Contents</Button>
<Button appearance="stroke" leadingIcon={<RiQuestionAnswerLine />} aria-expanded={sheet} onClick={openSheet}>Ask tutor</Button>
<article><Lesson /></article>
{drawer && <Drawer label="Lesson contents"><LessonContents /></Drawer>} {/* z-index: var(--nu-z-overlay) */}
{sheet && <Sheet label="Tutor"><TutorPanel /></Sheet>} {/* z-index: var(--nu-z-overlay) */}
</>
)The width is simulated with the toggle so the demo works on any screen. Opening a panel moves focus to its Close button; Escape or Close returns focus to the button that opened it. The frame uses isolation: isolate, so its overlay layers stay inside it.
Pending: mobile bottom navigation (D23)
The bottom-nav item set needs a product decision. Figma shows New · Chat · Library · Profile; the live app has New · Library · Spaces · Profile. There's no BottomNav pattern until the items are confirmed. Tablet layouts and the three-column collapse are on the Figma backlog.
Layering
Search opens the CommandMenu: its scrim sits on --nu-z-overlay (40) and the dialog on --nu-z-modal (50), both above this site's sticky header (--nu-z-sticky, 20). The menu uses --nu-z-popover (60).
<Button appearance="stroke" leadingIcon={<RiSearch2Line />} onClick={() => setOpen(true)}>Search lessons</Button>
<DropdownMenu> {/* content: z-index var(--nu-z-popover) */}
<DropdownMenuTrigger asChild>
<Button appearance="ghost" iconOnly aria-label="Lesson actions"><RiMore2Line /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem leading={<RiEditLine />}>Rename</DropdownMenuItem>
…
</DropdownMenuContent>
</DropdownMenu>
<CommandMenu open={open} onOpenChange={setOpen} label="Search lessons"> {/* scrim: overlay, dialog: modal */}
<CommandMenuGroup heading="Biology 101">
<CommandMenuItem leading={<RiBookOpenLine />}>Photosynthesis</CommandMenuItem>
</CommandMenuGroup>
</CommandMenu>Focus
--nu-shadow-buttons-*-focus). Tab to the button. Below 3:1 (C5).Accessibility
Layout decides whether content survives zoom and narrow screens, and whether the keyboard position can be seen.
| Check | WCAG | Status | What it takes |
|---|---|---|---|
| Reflow at 320 CSS px | 1.4.10 (AA) | Partly verified | Content fits one column at 320px wide (1280px at 400% zoom) with no sideways scrolling, except tables, code and matrices, which scroll inside their own focusable frame. Component pages are checked at 375px with no page scroll (template §7); a 320px check is still open. CommandMenu narrows to the window minus --nu-space-8. |
| Focus visible | 2.4.7 (AA) | Gap (C5) | Every focusable element shows an indicator. Components show Figma's focus effect, about 1.1:1 against white, so it's hard to see on filled buttons. The interim focus-ring colour measures 5.59:1 and meets the 3:1 non-text contrast the Stable gate requires. |
| Focus not obscured | 2.4.11 (AA) | Mitigated on this site | A sticky header (--nu-z-sticky) can cover a focused element. This site sets scroll-padding-block-start to the header height plus --nu-space-6. Overlays on --nu-z-overlay and up must move focus into themselves and return it on close. |
| Forced colours | 1.4.11, 2.4.7 | Gap (C5) | Figma's focus effects are box shadows, which Windows High Contrast removes. An outline (the focus-ring tokens) stays visible. |
You must: never hide content at small widths that people need (move it into a drawer or sheet instead); keep focus order matching the visual order after a layout rearranges; never remove an outline without a visible replacement.
Guidelines and related
Use layout tokens for
- Page-level rearrangement: columns becoming drawers and sheets.
- Stacking any positioned surface: sticky bars, scrims, dialogs, menus.
- Focus indicators on your own interactive elements.
Don't use them for
- Components inside panels. Use container queries, not window breakpoints.
- Space between elements. Use spacing tokens.
- Overriding focus on
.nu-*components, which draw their own.
Do and don't
z-index: var(--nu-z-popover);z-index: 9999;