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.
MarkdownThe 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
| Environment | Where it shows |
|---|---|
| Coarse pointer | taller grid rows, larger days and buttons, handles that reach past corners, steppers offered, sheets |
| Pointer type | drag thresholds of 3, 6 and 10 pixels for mouse, pen and finger; a tap that toggles; a hold for a menu |
| Pointer movement | a menu's highlight follows the pointer's own movement only |
| Command key | toggling selection, moving to an edge, undo and redo, select all |
| Direction | arrow keys in lists, menus, grids and calendars; a calendar's layout; a scrub's direction |
| Locale | numbers and dates read and shown; the week and the weekend; the digits a calendar writes |
| Less motion | overlays and sheets appear and return without gliding |
| Forced colours | focus rings, selection, handles, guides and unavailable items in the system's colours |
| Safe areas | sheets keep clear of the bottom edge |
| On-screen keypad | a 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.jsonSource
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;
}