Interfaces
Creating functional, frictionless interfaces still brings me genuine enjoyment, and I understand the importance of making systems accessible to people with very different levels of technical ability.
Interface x-ray
This is a small booking form, built the way I build them. Drag the divider across it: to the right is the form as you see it, and to the left is what the same markup gives a browser, a keyboard and a screen reader.
None of it is drawn by hand. It is read from the live page, so fill the form in, or submit it empty, and the x-ray changes with it.
name, so nothing you enter is sent anywhere. Screen
readers word things differently from each other, but the role, name and state shown here are what they all
work from.
Keyboard first
I start with semantic structure: landmarks, real headings, real buttons and links, and a skip link. That gets most keyboard behaviour without any script. I add ARIA where HTML runs out, for a role, a name or a region that announces a change.
What is left is managing focus. Open the dialog and watch it: focus moves in, the page behind goes inert, Escape closes it, and focus returns to the button it came from.
Focus is on
nothing yet: press Tab
Where it has been, newest first
It is the first Tab stop on every page of this site, and stays out of sight until it has focus. The button sends focus to it, at the top of the page. Escape brings you back here.
document.activeElement, given by its role and accessible name each time focus
moves anywhere on this page.
Contrast you can measure
Colour on this site is assigned by role, not by value: page, panel, text, link, button, rule. There are six palettes, and for each one the contrast of every pairing is measured when the site is built.
The same function is running here. Pick a palette, then fade its text towards its background and watch the ratio fall through 4.5 to 1, the least that body text needs, and 3 to 1, the least for large text.
Two-colour print
A two-ink print job in blue and fluorescent pink on plain white stock.
Text on page
16.98:1
#131a3a on #ffffff
- Passes for body text (4.5:1)
- Passes for large text (3:1)
| Pairing | Sample | Ratio | Needs | Result |
|---|---|---|---|---|
| Text on page | 17.0:1 | 4.5:1 | Pass | |
| Secondary text on page | 6.7:1 | 4.5:1 | Pass | |
| Link on page | 7.0:1 | 4.5:1 | Pass | |
| Text on panel | 15.3:1 | 4.5:1 | Pass | |
| Secondary text on panel | 6.1:1 | 4.5:1 | Pass | |
| Link on panel | 6.4:1 | 4.5:1 | Pass | |
| Button text on button | 7.0:1 | 4.5:1 | Pass | |
| Rule on page | 3.7:1 | 3:1 | Pass |
Explorer sheet
An OS Explorer map with the paper taken away: the green wash of woodland as the page, cover orange for buttons, water blue for anything you can follow.
Text on page
10.67:1
#14301f on #cfe6bd
- Passes for body text (4.5:1)
- Passes for large text (3:1)
| Pairing | Sample | Ratio | Needs | Result |
|---|---|---|---|---|
| Text on page | 10.7:1 | 4.5:1 | Pass | |
| Secondary text on page | 6.1:1 | 4.5:1 | Pass | |
| Link on page | 6.7:1 | 4.5:1 | Pass | |
| Text on panel | 12.1:1 | 4.5:1 | Pass | |
| Secondary text on panel | 6.9:1 | 4.5:1 | Pass | |
| Link on panel | 7.6:1 | 4.5:1 | Pass | |
| Button text on button | 5.2:1 | 4.5:1 | Pass | |
| Rule on page | 3.9:1 | 3:1 | Pass |
Kingfisher
The fish in albofish, by way of the RSPB: deep river teal, the electric blue of the back and the orange of the breast.
Text on page
12.03:1
#eaf6f7 on #0c3440
- Passes for body text (4.5:1)
- Passes for large text (3:1)
| Pairing | Sample | Ratio | Needs | Result |
|---|---|---|---|---|
| Text on page | 12.0:1 | 4.5:1 | Pass | |
| Secondary text on page | 7.6:1 | 4.5:1 | Pass | |
| Link on page | 8.0:1 | 4.5:1 | Pass | |
| Text on panel | 9.6:1 | 4.5:1 | Pass | |
| Secondary text on panel | 6.1:1 | 4.5:1 | Pass | |
| Link on panel | 6.4:1 | 4.5:1 | Pass | |
| Button text on button | 6.2:1 | 4.5:1 | Pass | |
| Rule on page | 5.4:1 | 3:1 | Pass |
Camp blanket
A camp blanket in red wool: the page is the blanket, the text is stitched in white and the badges are mustard.
Text on page
7.68:1
#fff8f2 on #9c1b26
- Passes for body text (4.5:1)
- Passes for large text (3:1)
| Pairing | Sample | Ratio | Needs | Result |
|---|---|---|---|---|
| Text on page | 7.7:1 | 4.5:1 | Pass | |
| Secondary text on page | 5.6:1 | 4.5:1 | Pass | |
| Link on page | 5.5:1 | 4.5:1 | Pass | |
| Text on panel | 9.6:1 | 4.5:1 | Pass | |
| Secondary text on panel | 7.0:1 | 4.5:1 | Pass | |
| Link on panel | 6.9:1 | 4.5:1 | Pass | |
| Button text on button | 8.4:1 | 4.5:1 | Pass | |
| Rule on page | 4.5:1 | 3:1 | Pass |
Night hike
A fell after dark: navy sky, head-torch yellow, and the cold blue of a phone screen.
Text on page
14.85:1
#e8edf5 on #0f1a2b
- Passes for body text (4.5:1)
- Passes for large text (3:1)
| Pairing | Sample | Ratio | Needs | Result |
|---|---|---|---|---|
| Text on page | 14.8:1 | 4.5:1 | Pass | |
| Secondary text on page | 7.9:1 | 4.5:1 | Pass | |
| Link on page | 12.1:1 | 4.5:1 | Pass | |
| Text on panel | 12.8:1 | 4.5:1 | Pass | |
| Secondary text on panel | 6.8:1 | 4.5:1 | Pass | |
| Link on panel | 10.4:1 | 4.5:1 | Pass | |
| Button text on button | 12.1:1 | 4.5:1 | Pass | |
| Rule on page | 8.1:1 | 3:1 | Pass |
Autumn hillside
A Lakeland fellside in October: faded grass for the page, heather for links and buttons, a line of rusted bracken.
Text on page
9.30:1
#262a1c on #ccd1ae
- Passes for body text (4.5:1)
- Passes for large text (3:1)
| Pairing | Sample | Ratio | Needs | Result |
|---|---|---|---|---|
| Text on page | 9.3:1 | 4.5:1 | Pass | |
| Secondary text on page | 5.4:1 | 4.5:1 | Pass | |
| Link on page | 6.0:1 | 4.5:1 | Pass | |
| Text on panel | 10.9:1 | 4.5:1 | Pass | |
| Secondary text on panel | 6.3:1 | 4.5:1 | Pass | |
| Link on panel | 7.1:1 | 4.5:1 | Pass | |
| Button text on button | 8.3:1 | 4.5:1 | Pass | |
| Rule on page | 3.5:1 | 3:1 | Pass |
Motion you can switch off
Movement on a screen helps some people follow what has changed and makes others feel unwell, so I treat it as the visitor's choice. Anything I animate has a reduced version that reaches the same end state with an instant change or a short fade.
The switch in the header does this for every animation on the site, and it follows your system setting until you touch it. This is the same switch.
With motion
Place booked
Their name is on the list for the camp.
Slides down into place.
Motion reduced
Place booked
Their name is on the list for the camp.
Fades in where it will stay.
Rhythm and small details
I love the small details and rhythms of a well-designed interface: spacing that repeats, a type scale with only a few steps, targets big enough for a thumb. The typeface throughout is Atkinson Hyperlegible, which was drawn for the Braille Institute and chosen here for its legibility.
The block shown here is built from this site's own components. Choose what to measure and the figures are taken from the live elements.
What I build interfaces with, on the server and in the browser
These are the frontend tools I use most. How this site itself is put together is written up in the style guide.
- TypeScript
- Vue
- Alpine.js
- Tailwind CSS
- Inertia
- Livewire
- Filament
- Three.js
Types from the database to the button
On the Vue side I write TypeScript throughout, and the types are generated from the PHP data objects on the server, so the two cannot drift apart. A type-check step catches a renamed or misspelt field before it ships.
Tests protect the interface as well: component tests, browser tests, and convention tests that scan the source and fail if someone hand-builds a form control instead of using the shared one.
-
A PHP data object
The shape of a booking is declared once, on the server.
final class BookingData extends Data { public function __construct( public string $name, public Section $section, public bool $consent, ) {} } -
A generated TypeScript type
A build step writes the matching type, so nobody keeps the two in step by hand.
// Generated from BookingData. Do not edit. export type BookingData = { name: string; section: Section; consent: boolean; }; -
A Vue component prop
The component takes the type as a prop. This one asks for a field that is not there.
<script setup lang="ts"> import type { BookingData } from '@/types'; defineProps<{ booking: BookingData }>(); </script> <template> <p>{{ booking.fullName }} is booked</p> </template> -
The type-check stops it
The check fails on the field that does not exist, so the mistake is found before it ships.
$ npm run type-check BookingCard.vue:8:17 - error TS2339: Property 'fullName' does not exist on type 'BookingData'.