Getting started
Felt and clean
felt-css comes in two looks. One attribute on the html element switches between them, and nothing on the page moves when you do.
View page source on GitHubOn this page
Switching looks#
Without any attribute you get the clean look: flat surfaces, hairline borders, Bootstrap-like. Add data-look="felt" to the <html> element and the same page is cut from wool felt and sewn together.
<html lang="en" data-look="felt">Try it with the Felt / Clean switch at the top of this page: every example in these docs follows it.
The look is a page-wide setting: the felt tokens hang off :root[data-look="felt"], so put the attribute on <html>, not on a single section.
Remembering the choice#
felt-css doesn't store anything itself. If you offer a switch, set the attribute before the first paint so the page doesn't flash, for example with a tiny inline script in the <head> — this is what these docs do:
<script>
(() => {
let look = null
try { look = localStorage.getItem('look') } catch {}
document.documentElement.dataset.look = look === 'clean' ? 'clean' : 'felt'
})()
</script>How the felt works#
Two felt recipes#
Saturated pieces (buttons, stat tiles, badges, the active pagination item) are patches: a photo of grey felt blended soft-light onto the colour, a soft cut edge and a cast shadow. Light surfaces (page, cards, alerts, list groups) are quiet felt: a photo of cream felt blended multiply, flat, at the same fibre scale. In dark mode the sheet is charcoal felt.
Real thread#
Seams are 9-slice images built from one photographed stitch and laid on with border-image. They're tinted with mix-blend-mode: hard-light, so the thread is always a tone of the felt underneath: lighter on colour, a shade darker on cream. Dividers inside a piece (card headers, table rows, .vr, <hr>) are single rows of stitches.
Never moves#
The felt layer only changes colours, textures, shadows and seams — never padding, borders or fonts. Toggling the look doesn't shift a single pixel, so you can design in clean and switch to felt at the end, or let your users choose.
Opt-out friendly#
All felt rules sit in @layer felt behind :where([data-look="felt"]), so they have zero specificity and your own CSS always wins. To keep one element flat, just style it.
Weight#
felt.css is about 33 KB gzipped. The felt photos and seam images in img/ add about 42 KB and are only downloaded when the felt look is on.
Rebuilding the assets#
The photos in raw/ were drawn by an image model: grey felt, cream felt and a single running stitch. tools/build_assets.py turns them into the textures and seam frames in img/ and writes the matching sizes into felt.css. Want chunkier stitches or rounder corners? Change STITCH_CSS, GAP_CSS or SHAPES at the top of the script and run it again.
python3 tools/build_assets.py # needs numpy and ImageMagick