Typography, layout and motion
Read when: you set fonts, size text, space a page, round a corner or animate anything.
Owns: the typefaces and how to load them, the type scale, page widths, spacing, radius, borders, tap targets,
and every duration, curve and animation.
Values match the site's components and main.css (checked 2026-11-03).
TL;DR
- Geist for everything you read, Geist Mono for labels, dates and code, Instrument Serif italic for one phrase.
- Headings are 600 with negative tracking; body text is 15–17 px at line height 1.7; paragraphs stop at 70ch.
- One column: 880 px, 1040 px for wide pages, 720 px for long-form reading, 24 px side gutter.
- Flat cards: 1 px
line border, radius 14–16 px, no shadows.
- One curve,
cubic-bezier(0.23, 1, 0.32, 1), 200 ms for colour changes. Everything stops under reduced motion.
Typefaces
| Face |
Weights |
Use |
| Geist |
400, 500, 600 |
headings and everything you read |
| Geist Mono |
400, 500 |
eyebrows, dates, meta lines, badges, counts, repository names, code |
| Instrument Serif |
400 italic |
one human phrase per page, usually with .text-gradient |
| Excalifont |
400 |
labels inside diagrams only |
- Never another face. Arabic text is the one exception (below).
--font-serif is the token name for Instrument Serif, as on the site.
Loading the fonts
- The site self-hosts them from npm with Fontsource: the Geist and Geist Mono variable packages, and only the Latin
400 italic file of Instrument Serif. No font service is called. The imports are in the Tailwind setup in
code.md.
- A quick page can load the same three from Google Fonts; the
<link> is in the plain HTML starter in
code.md.
- The tokens file lists both family names (
"Geist Variable", "Geist"), so either source works without edits.
- Excalifont is self-hosted from Excalidraw's font files with
font-display: swap, Latin subset only.
Arabic
Geist has no Arabic letters. Pair it with an Arabic face of similar weight (for example IBM Plex Sans Arabic) set
after Geist in --font-sans, so Latin words stay in Geist. Set <html lang="ar" dir="rtl">, use logical properties
(ms-, pe-, text-start), and wrap a Latin value inside Arabic text in dir="ltr". The site itself is English
only, so check an Arabic page by eye before shipping it.
Type scale
| Role |
Size / line height |
Weight, tracking |
Colour |
| Display (brand page) |
44 px / 1.08 |
600, -0.03em |
fg, a quiet second line in faint |
| Section page title |
34 px / 1.08, 46 px / 1.06 from 768 px |
600, -0.03em, max 720 px wide |
fg |
| Page title |
28 px / 1.25, 30–32 px from 768 px |
600, -0.02em, text-balance on articles |
fg |
| Card title |
17 px |
600, -0.01em |
fg |
| Lead |
17 px / 1.7 |
400 |
soft |
| Body, secondary |
15 px / 1.7 |
400 |
muted |
| Card text |
14 px / 1.6 |
400 |
muted |
| Nav links |
14 px |
400 |
muted, hover and current fg |
| Filter chips, tech chips |
13 px |
400 |
muted or soft, selected fg |
| Small, captions, footer |
13–13.5 px / 1.6 |
400 |
muted or faint |
Eyebrow (.eyebrow) |
12 px |
Mono 500, uppercase, 0.08em |
faint |
| Meta line (date · category · time) |
12–13 px |
Mono 400 |
faint |
| Badge |
9.5–12 px |
Mono, uppercase, 0.1em |
.text-gradient |
| Gradient word |
about 1.3× the text around it (22 px in 17 px) |
Instrument Serif italic |
.text-gradient, pr-0.5 so the italic is not clipped |
- Section titles on a page are eyebrows, numbered on the brand page (
01 · The signature).
- Long-form text uses Tailwind Typography mapped to the tokens (see code.md).
Page layout
| Item |
Value |
| Page column |
880 px max, centred, 24 px side padding |
| Wide page (brand, project pages) |
1040 px max |
| Long-form reading (articles) |
720 px max |
| Running text |
70ch max (set on main p by the tokens file) |
| Page top |
64 px, 80–88 px from 768 px |
| Between sections |
72 px |
| Header |
16 px from the top, 28 px from 768 px |
| Footer |
96 px above it, line border on top, 28 px top and 40 px bottom padding |
| Project sections |
each h2 sits under a line rule, 56 px margin and 44 px padding above it |
| Side table of contents |
176 px column beside the text from 1024 px, sticky, a line rail with the current item in fg |
Spacing, radius and borders
- Grids:
repeat(auto-fit, minmax(min(100%, 200px), 1fr)) with 12–16 px gaps; raise the minimum (220–320 px) for
bigger tiles. Why: columns collapse on their own at phone widths with no breakpoints.
- Card padding 18–22 px; list rows 10 × 14 px.
- Borders are 1 px in
line; hovered rows add surface fill and a chip border. Cards are flat: no drop shadows.
- Tap targets are at least 44 px (
min-h-11, size-11). A smaller-looking control stretches its hit area with a
::before.
| Radius |
Where |
| 4 px |
focus ring |
| 5–6 px |
badges, copy button |
| 8 px |
icon buttons, small rows |
| 10–12 px |
brand-kit rows, figures, file tiles |
| 14–16 px |
cards, swatches, social card previews |
| 22 px |
hero frame |
| full |
pills: filter chips, the contact link, tech chips, status lines |
| size ÷ 4 |
logo tiles |
| about 22–23% |
favicon and app icon (see logo.md) |
Motion
| What |
Value |
| Colour, background and border changes |
200 ms, cubic-bezier(0.23, 1, 0.32, 1) (--ease-out-soft) |
Entrance (.animate-rise) |
0.7 s, cubic-bezier(0.2, 0.7, 0.2, 1), from 8 px lower and transparent; stagger about 0.1 s per block |
Live dot (.animate-live) |
2.2 s ease-out, infinite: an Iris ring grows to 9 px and fades |
Arrow on "More about me →" links (.go) |
moves 3 px right in 0.25 s |
| Mobile menu |
overlay fades in 200 ms, out 150 ms; items drop 6 px over 0.32 s, 28 ms apart (.menu-item, --i) |
| Menu button |
two lines turn into a cross in 300 ms |
| Portrait on the home page |
hover tilts −3° and scales 1.04 in 0.3 s, with a 1.5 px chip ring |
| Copied state |
holds 1.6 s, then returns |
| Signature |
draws stroke by stroke (see logo.md) |
- Under
prefers-reduced-motion: reduce, every animation and hover movement stops; the tokens file does this for
its own classes, and Tailwind transitions get motion-reduce:transition-none.
- Nothing loops except the live dot.