material 3 in a personal site: what survived, what did not

dev · Oct 4, 2026 · 5 min read

this site runs on material 3. not inspired by it, not loosely based on it — the actual token names, the actual palette schemes, the actual shape language. i wanted to know whether a design system built for a phone app survives contact with a personal site, which has completely different priorities.

short answer: about 70% of it survived, and the 30% that died is the part i am most glad i dropped.

what went in

the token layer, which is the real thing. --primary-container, --on-primary-container, --surface-variant, --outline-variant — those are the m3 names, not my own names, and every component consumes them rather than hardcoding a colour. this is the part that paid off the most, and not for aesthetic reasons.

it paid off because i switched the whole site from light to dark and back, and changed the accent, six times, while writing it. nothing broke. there is no if (dark) in my components. every colour in the stylesheet resolves through a variable, and the dark palette is a second set of values for the same names.

all six palette schemes: tonal-spot, fidelity, content, neutral, expressive and fruit-salad. they are in the settings dialog and they all work, because they are what m3 calls them and not six hand-tuned themes.

the shape scale. m3 has a defined corner radius ladder and cards sit at the top of it. my cards are rounded-[2.4rem], buttons go fully rounded, nav pills are fully rounded. this is the single most recognisable m3 tell and it survives translation to a personal site without any trouble.

the ripple, at the correct opacity:

opacity: var(--ripple-hover-opacity, 0.08);

0.08 is the m3 state layer value. it feels almost too subtle, and that is correct — the point of the state layer is that you should not consciously notice it.

ripple scope. this was the part i did not know i needed. the ripple only follows the pointer inside the element you actually pressed, not the whole page. it makes fast clicks feel precise instead of sloppy.

the components that made sense as small web versions: switch, slider, text field, and two scrollbars, one for the window and one for panels. those are pure wins.

what i threw out

elevation. m3 defines six levels, 0 through 5, with a specific shadow for each. i use none of them. there is not one elevation token in my stylesheet, and the only shadows in it are hover states on cards and buttons — nothing that describes how high a surface sits.

this was not laziness, it was a decision. m3's elevation model assumes opaque surfaces stacked on each other. this site has a translucent sidebar with a backdrop blur, and content scrolling underneath it. elevation is a depth metaphor for opaque planes; when the plane is see-through, the shadow lies about what is on top of what. so borders do the separating here, not shadows.

the m3 elevation values are good and i copied the spirit — borders at --outline-variant, radius at the top of the scale — without copying the tokens.

the typography scale. m3 has five roles: display, headline, title, body, label, each with three sizes. i use roughly two of them, because a display role sized for a phone gets you about 57px, which is not a headline on a wide screen. the scale does not survive the jump from a 6-inch viewport to a 2560px one without being rescaled, and once you have rescaled it you are no longer using m3's scale, you are using yours that resembles it.

i kept the idea — a single expressive display face for the big moments, a monospace for the developer surfaces, plain sans for body — and threw away the specific ramp.

the one that never worked

motion.

m3 specifies durations and easing curves, and they are tuned for a phone: short, because a thumb is close to the screen. on a desktop pointer-driven site those same curves feel sluggish in a way that is hard to put into words. my eye is not where my thumb would be.

the bigger problem is that motion-heavy specs assume a reliable frame budget. they do not assume the frame budget might be zero.

i hit this hard. my sidebar collapse animation silently stopped working: the box stayed at full width, forever, with no error anywhere. the cause was not the animation logic but a layout projection fighting a width transition on the same element — two things both trying to own the box, and the loser never reached its final value. and the only reason i found it was measuring the computed width instead of trusting that the code "looked right".

i moved the width from a motion value into a plain css transition, and deleted the layout projection. the box now reaches its target within 60ms, measured, every time.

the lesson was not about framer motion. it was that a design system tells you what a finished state looks like and says nothing about how you get there when the frame budget collapses. for a phone app that assumption holds. for a web page running in a browser tab someone might have backgrounded, it does not.

what i would keep if i started again

the token layer, without hesitation. it is the part of m3 that is genuinely reusable outside android, and the reason is boring: it is a naming scheme that forces you to answer "what is this colour's role" instead of "what colour is it". that question is worth asking even if you never open the material docs.

the palette schemes and the shape scale, because they are free once the tokens exist.

the state layer opacity, because it is one number and it is right.

and the elevation, the type ramp and the motion curves, gone, with no regret — they are all assumptions about a context this site is not in.

the honest summary: m3 is a very good answer to "how does a native android app look and feel", and a decent starting point for a web design system, provided you are willing to treat it as a vocabulary rather than a specification. the tokens transfer. the pixel values do not.