Skip to content

Building a design system for dusk

Starting from a single hex value, turning gentleness and restraint into rules code can follow.

Contents

This design system has never been released. It has no version number, no changelog, and no documentation beyond a few files with comments. It is just the layer of order behind this site — something I changed a little at a time for two years, until it started doing some of the work for me.

I am writing it down because the hard part turned out to sit somewhere other than I expected.

One grey, forty-odd files

At the start I wrote hex values straight into components: a border of #E5E0D8, body text in #2B2A28, a background of #F5F1EA. It was fast, and nothing looked wrong.

Six months later I wanted a dark theme, and found I had built a maze. The same grey lived in more than forty files. Some were uppercase, some lowercase, and one had quietly become #E4E0D8 through a typo nobody caught — including me.

So one night I did something stubbornly manual: I collected every colour in the codebase into one list. The page held eleven colours. In the code they had more than a hundred names.

A token names an intention, not a colour

Colour then collapsed into semantic tokens: --bg, --surface, --text, --muted, --border, --accent.

The point was not that there were suddenly fewer of them. They answered a different question: no longer what the colour is, but what the colour is for.

A colour should be named for the job it does, not for what it happens to look like on the palette.

Since then no component holds a raw colour — not out of fastidiousness, but because keeping a decision in one place is the only thing that makes it changeable later.

Two palettes, and no rainbows

Light mode sits on old paper: #F5F1EA, not white. Pure white is too clean, too free of time, like paper still in its wrapper. Old paper carries a trace of yellow — the colour of a letter unfolded more than once.

Dark mode is night blue: #0E1018, not black. Black cuts a hard edge around every letter and tires the eyes. A little blue makes the dark read as night, rather than a screen that has been switched off.

There are exactly two accents: dusk orange #C96F5A on light, #E08B72 on dark. The dark one is lighter, because a dark ground swallows both weight and warmth.

Gradients only ever move within one family. Dusk runs #0E1018 → #2A1F3D → #4A2C3A: out of night, into violet, back to a little leftover warmth — the order in which the sky gives itself up.

No rainbow gradients. Not because they are ugly, but because they are loud. On a quiet page they would become the only thing anyone remembers, and I want the writing remembered.

Time is a token too

The problem with motion was never speed. It was that every animation arrived differently — one slid, one bounced, one faded.

Everything now runs on a single curve: cubic-bezier(0.22, 1, 0.36, 1). It moves early, lands slowly, and is still travelling a little in that final stretch. Like dusk — the sky does not go dark all at once.

Durations come in two sizes. Micro-interactions take 250–350ms, because a button has to confirm the press immediately. Entrances take 800–1200ms, because settling down takes longer than reacting. The nameless animations in between — half a second long, expressing nothing — were deleted.

Underneath sits one hard rule: animate transform and opacity only. They skip layout and paint, and they do not stutter on an old phone.

The hardest work is deciding what not to do

Deleting is harder than adding, because adding pays you back immediately and deleting pays you back much later.

I once made a button bounce. It was charming, and pleasant for about three clicks. By the fourth it had become noise, and the people it interrupted were exactly the ones who had come to read one quiet essay.

So the list exists: no bouncing, no spinning, no flashing, no hover that suddenly grows. On mobile, parallax and cursor-follow are off entirely — on a device with no cursor, following is only ever a delay.

Restraint is not doing less. It is clearing space for the one thing that matters.

That sounds like a consolation prize, I know. But every time I resisted an addition, the later version of me was grateful.

Setting Chinese and English together

The site is bilingual, so one set of styles has to hold both languages — and they disagree about what comfortable means.

Chinese characters are dense squares. They need air: line-height 1.8, measure 36–40em. Tighten it to 1.5 and two or three lines in, you want to look away.

English breathes through the shapes of lowercase letters and the gaps between words. 1.6 is plenty, but the measure has to reach 65–75ch. Shorter, and the lines break constantly — like reading while walking downstairs.

The typefaces split as well. Chinese headings use LXGW WenKai, whose strokes carry a handwritten ease and keep a heading from sounding like an announcement. English headings use Newsreader, a quiet serif. The same paragraph needs two rhythms of breathing; no single number serves both.

The one that broke: max-w-[20ch] on the container

This is the part I most wanted to write down, because it failed so concretely.

To stop a large headline from running too long, I put max-w-[20ch] on the container wrapped around it — keep it to about twenty characters, roughly.

The headline became a narrow tower, roughly one character per line.

Because ch is a relative unit: it is measured against the font size of the element it is written on. The container held body text at 17px, so 20ch came to under 400px, while the headline inside it was 44px. Of course it broke after every character.

A width constraint belongs on the element that owns the type, not on the box around it.

What that bug taught me was not the definition of ch, but a more general instinct: write a constraint on the thing it constrains.

prefers-reduced-motion is a floor, not a bonus

Some people feel dizzy or nauseous from motion, because of how their vestibular system works. That is not a preference. That is a body.

So when prefers-reduced-motion applies, I remove every entrance animation and leave only a small change in opacity: let the content arrive instead of moving.

This is not a bonus point. If a page only works while it is moving, it was never thought through.

None of this was released or documented. What it left behind is a habit: when something needs to change, there is exactly one place to change it.

That is the whole return on the work. Not the number of rules — I have forgotten most of them — but that the next change costs less.

Dusk sounds like a light theme. The thing that keeps it steady is not romantic at all: a set of variables named boringly enough to be trustworthy, one easing curve that never needed changing, and a list of things I decided not to do.

The sky does not go dark all at once. Neither should a page.

ShareX / Twitter
You might also like

You might also like

Comments3

  1. 夜航船2026.09.14

    写得真好,尤其是决定不做什么那一段。

  2. 夜航船2026.09.14

    决定不做什么那一段,我读了两遍。

  3. 路过的人2026.09.14

    缓存失效测试评论。

Write a comment

Not published
Optional — shown next to your name once approved
Your comment is awaiting review.
Letters

If you liked this, letters are open

Roughly one or two a month · No ads, no forwarding · Used only to send letters. Never shared.

Contact

Tell me what you are working on and what you need.

Usually replies within 1–2 business days