Skip to content
Alex Hollyman

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.

Book a place on the camp

Place booked

This is a demonstration, so nothing was sent.

Book another place

Dietary needs (optional)

A demonstration form: its fields have no 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.

Cancel the booking?

Their place goes back on the list for someone else.

Focus is on . The page behind is inert, and Escape closes this.

The readout is 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)
Every pairing the site uses in Two-colour print, measured with the WCAG formula.
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)
Every pairing the site uses in Explorer sheet, measured with the WCAG formula.
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)
Every pairing the site uses in Kingfisher, measured with the WCAG formula.
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)
Every pairing the site uses in Camp blanket, measured with the WCAG formula.
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)
Every pairing the site uses in Night hike, measured with the WCAG formula.
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)
Every pairing the site uses in Autumn hillside, measured with the WCAG formula.
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
The figures for all six palettes are in the style guide.

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.

One notification, built once. The first obeys the switch; the second shows what the switch turns it into.

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
The dashed square under Touch targets is 44 pixels, the size I treat as the least a button should be. A link inside a sentence is the exception: it takes the height of its line.

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.

  1. 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,
        ) {}
    }
  2. 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;
    };
  3. 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>
  4. 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'.
The names in the code are made up for this page. The pattern is the one I use.