Foundations

Adapting to the environment

Reading the pointer in use, the platform's command key, the direction of text, the language and the person's settings, and doing what is right there.

Markdown

The same interface is used with a mouse and with a finger, on a Mac and on Windows, in English and in Arabic, by someone who has asked for less motion and by someone whose system draws every colour itself. None of these is the default from which the others are adapted. Each control reads the environment it is in, when it is used, and does what is right there — with nothing for the person, or the application, to set.

Best practices

Read the environment when it is used, not once. A tablet gains a trackpad when its keyboard is attached; a page changes its language; a window moves to a screen that forces its colours. What a control does is decided by what is true at the moment of the press, the key or the paint — never by what was true when the page loaded.

Ask about the pointer that is actually in use. A finger is broad, has no hover and no modifier keys, and covers what it presses; a mouse is precise, hovers, and holds Shift. Where a control differs between them, it asks which is pressing it now — the press's own pointer type, or whether the primary pointer is coarse — not what kind of device the page is on. A laptop with a touchscreen is both, one press at a time.

Size for a finger where a finger is used. Targets are at least 44 pixels across where the pointer is coarse: a grid's rows grow, a calendar's days grow, the handles of a selection reach past its corners. Where the pointer is fine, the same interface stays dense.

Give a finger what its lack of keys takes from it. Where a mouse adds to a selection with the command key, a finger's tap adds by itself; where a mouse right-clicks, a finger holds. Where a mouse hovers to find a control, a finger is shown it.

Hover is the pointer's own movement. A browser reports a move when the page moves beneath a pointer that is resting, as well as when the pointer moves. A highlight that follows the pointer follows only the pointer: a list that scrolls under a resting mouse does not hand its highlight to whatever comes beneath.

Use the platform's command key. Shortcuts and multiple selection use ⌘ on Apple platforms and Ctrl elsewhere. On a Mac, Ctrl with a click is a secondary click, so it is never read as the command key. An iPad that reports itself as a Mac is treated as one, which is what its keyboard is.

Mirror for the direction text runs in. Where text runs right to left, so does everything laid out along a line: the start of a row is on the right, the arrow keys that move along a line are mirrored, an arrow that points forward points left, and a drag to the left increases a value scrubbed along a line. Up and down are not mirrored. The direction is read from the control's own place in the page, so one part of a page may run each way.

Write in the reader's language. Numbers, dates, the first day of the week and the weekend follow the locale the control is given, or else the language the page is written in — its lang — or else the browser's. A page that changes language is followed. A tag that is not a language, such as en_US, falls back to the next in that order rather than failing.

Move less for those who ask. Where the person has asked for less motion, transitions that only decorate are dropped, and motion that would carry something into place — a sheet returning after it was let go — happens at once. Motion that follows a finger is the finger's, and stays.

Keep what forced colours need. Where the system forces its own colours, borders, focus and selection must still be visible: a focus ring is an outline, which a forced palette keeps, and not a shadow, which it discards; selection, handles and guides take the system's highlight colour; an unavailable item takes its colour for unavailable text.

Stay clear of the device's edges. Something fixed to the bottom of a phone's screen — a sheet — leaves room for the home indicator and the curve of the screen.

Ask for the right keyboard. A field that takes a number offers a numeric keypad where the number is plain, and a full keyboard where it can be negative, carry a unit or take arithmetic, since a keypad has neither minus nor letters. Fields name what Enter does, and do not correct or capitalise what is not prose.

Render the same on a server. A server cannot know the reader's pointer or language. It renders for a fine pointer and in English, and the page corrects itself once it is in the reader's browser, without rendering twice where the answer was the same.

What the components do

EnvironmentWhere it shows
Coarse pointertaller grid rows, larger days and buttons, handles that reach past corners, steppers offered, sheets
Pointer typedrag thresholds of 3, 6 and 10 pixels for mouse, pen and finger; a tap that toggles; a hold for a menu
Pointer movementa menu's highlight follows the pointer's own movement only
Command keytoggling selection, moving to an edge, undo and redo, select all
Directionarrow keys in lists, menus, grids and calendars; a calendar's layout; a scrub's direction
Localenumbers and dates read and shown; the week and the weekend; the digits a calendar writes
Less motionoverlays and sheets appear and return without gliding
Forced coloursfocus rings, selection, handles, guides and unavailable items in the system's colours
Safe areassheets keep clear of the bottom edge
On-screen keypada numeric or decimal keypad for plain numbers; Enter named "done"

Platform considerations

Touch. Whether there is a touchscreen says little: a laptop may have one that is never used, and some browsers report touch points they do not have, or none that they do. What matters is the pointer pressing now, and whether the primary pointer is coarse.

Server rendering. Everything read from the environment is read lazily, when it is used, so that the code can be loaded where there is no browser.

Behaviour

The command key

On Apple platforms it is ⌘; elsewhere it is Ctrl
for (const agent of [MAC, IPHONE]) {
  on(agent);
  expect([isCommandKey(keys(true, false)), isCommandKey(keys(false, true))]).toEqual([true, false]);
}
for (const agent of [WINDOWS, ANDROID]) {
  on(agent);
  expect([isCommandKey(keys(true, false)), isCommandKey(keys(false, true))]).toEqual([false, true]);
}
An iPad, which calls itself a Mac, is an Apple platform
on("Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X)");
expect(isApplePlatform()).toBe(true);
on(MAC);
expect(isApplePlatform()).toBe(true);
The platform is read when it is asked about, not once when the code is loaded
on(WINDOWS);
expect(isApplePlatform()).toBe(false);
on(MAC);
expect(isApplePlatform()).toBe(true);

The pointer in use

Whether the pointer is a finger is the environment's answer: a phone's is, a mouse is not
const screen = await render(<Pointer />);
await expect.element(screen.getByRole("status")).toHaveTextContent(hasTouch ? "finger" : "fine");
When the pointer in use changes — a tablet's keyboard and trackpad taken off — the answer follows
const media = fakeMedia(false);
const screen = await render(<Pointer />);
await expect.element(screen.getByRole("status")).toHaveTextContent("fine");
media.change(true);
await expect.element(screen.getByRole("status")).toHaveTextContent("finger");
media.change(false);
await expect.element(screen.getByRole("status")).toHaveTextContent("fine");
await screen.unmount();
expect(media.listeners.size).toBe(0);
It is the primary pointer that is asked about: a touchscreen beside a mouse does not make the pointer a finger
vi.spyOn(window, "matchMedia").mockImplementation(
  (query) =>
    ({
      matches: query === "(any-pointer: coarse)",
      addEventListener: () => {},
      removeEventListener: () => {},
    }) as unknown as MediaQueryList,
);
const screen = await render(<Pointer />);
await expect.element(screen.getByRole("status")).toHaveTextContent("fine");
A page rendered on a server is rendered for a fine pointer
expect(renderToString(<Pointer />)).toBe("<output>fine</output>");

A pointer's own movement

A move to a new place is the pointer's own
const stop = watchPointer();
move(10, 10);
move(12, 10);
expect(pointerMoved()).toBe(true);
stop();
A move reported where the pointer already was is the page moving under it, not the pointer
const stop = watchPointer();
move(20, 20);
move(20, 20);
expect(pointerMoved()).toBe(false);
stop();
Watching is shared: one that stops leaves the others watching, and the last to stop stops it
const first = watchPointer();
const second = watchPointer();
move(1, 1);
move(2, 2);
expect(pointerMoved()).toBe(true);
first();
move(30, 30);
move(30, 30);
expect(pointerMoved()).toBe(false);
second();
move(40, 40);
// Unheard, so still the answer from before.
expect(pointerMoved()).toBe(false);

Locale

A locale given is the one used
document.documentElement.lang = "en-US";
const screen = await render(<Locale given="de-DE" />);
await expect.element(screen.getByRole("status")).toHaveTextContent("de-DE");
With none given, the document's language is used, and followed when it changes
document.documentElement.lang = "fr-CA";
const screen = await render(<Locale />);
await expect.element(screen.getByRole("status")).toHaveTextContent("fr-CA");
document.documentElement.lang = "ja-JP";
await expect.element(screen.getByRole("status")).toHaveTextContent("ja-JP");
A document with no language is in the browser's
document.documentElement.lang = "";
vi.spyOn(navigator, "language", "get").mockReturnValue("pt-BR");
const screen = await render(<Locale />);
await expect.element(screen.getByRole("status")).toHaveTextContent("pt-BR");
A tag that is not one falls back rather than failing: a given one to the document's, the document's to the browser's
document.documentElement.lang = "nl-NL";
const screen = await render(<Locale given="en_US" />);
await expect.element(screen.getByRole("status")).toHaveTextContent("nl-NL");
vi.spyOn(navigator, "language", "get").mockReturnValue("sv-SE");
document.documentElement.lang = "not a tag";
await expect.element(screen.getByRole("status")).toHaveTextContent("sv-SE");
A tag is written as Intl writes it; one it will not take is English, or what is asked for in its place
expect([
  validLocale("EN-us"),
  validLocale("zh-hant-tw"),
  validLocale("en_US"),
  validLocale("", "fr"),
]).toEqual(["en-US", "zh-Hant-TW", "en-US", "fr"]);
expect(validLocale("en_US", "ja-JP")).toBe("ja-JP");
A page rendered on a server is in English, since it cannot know its reader's language
function DocumentLocale() {
  return <output>{useDocumentLocale()}</output>;
}
expect(renderToString(<DocumentLocale />)).toBe("<output>en-US</output>");

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/environment.json
Source

src/lib/pointer.ts

import { useSyncExternalStore } from "react";

const QUERY = "(pointer: coarse)";

function subscribe(onChange: () => void): () => void {
  const media = matchMedia(QUERY);
  media.addEventListener("change", onChange);
  return () => media.removeEventListener("change", onChange);
}

/**
 * Whether the primary pointer is a finger. CSS answers this for looks; this is
 * for the few places that need the answer as a number, such as the row height a
 * virtual list computes with. A server renders for a fine pointer.
 */
export function useCoarsePointer(): boolean {
  return useSyncExternalStore(
    subscribe,
    () => matchMedia(QUERY).matches,
    () => false,
  );
}

let at: { x: number; y: number } | null = null;
let moved = false;
let watchers = 0;

function onMove(event: PointerEvent): void {
  moved = at === null || event.clientX !== at.x || event.clientY !== at.y;
  at = { x: event.clientX, y: event.clientY };
}

/**
 * Watch where the pointer is, for as long as something needs to tell a pointer
 * that moved from one that something moved under: a browser reports both as a
 * move. Returns what stops the watching.
 */
export function watchPointer(): () => void {
  if (watchers === 0) document.addEventListener("pointermove", onMove, { capture: true, passive: true });
  watchers += 1;
  return () => {
    watchers -= 1;
    if (watchers === 0) document.removeEventListener("pointermove", onMove, true);
  };
}

/** Whether the move being heard is the pointer's own, and not the page's beneath it. */
export function pointerMoved(): boolean {
  return moved;
}