Quiet card
Sits on the page with a thin ring.
A component library with a printed look: ink on paper, soft dithered shading where most interfaces use a flat tint, and every movement on a spring.
The web target is plain HTML, CSS and JavaScript, with no framework and no runtime dependencies. The GPUI target is a Rust crate. Both come from the same tokens, and tests hold their dither output to the same cells.
Every example on this page is the real component, running under the same strict policy the library promises to work under.
There is no build step. This copies the whole library into your project:
npx fluidither
It uses public/fluidither if the project has a public folder, and fluidither otherwise. Add --dir to choose another place.
Every page needs tokens.css, then the file for each component it uses. Scripts are ES modules, so serve the folder from a web server rather than opening the file.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello</title>
<link rel="stylesheet" href="fluidither/tokens.css" />
<link rel="stylesheet" href="fluidither/components/button.css" />
<link rel="stylesheet" href="fluidither/components/tabs.css" />
<link rel="stylesheet" href="site.css" />
<script type="module" src="fluidither/components/tabs.js"></script>
</head>
<body>
<fd-tabs value="week">
<fd-tab-list aria-label="Date range">
<fd-tab value="day">Day</fd-tab>
<fd-tab value="week">Week</fd-tab>
</fd-tab-list>
</fd-tabs>
<button class="fd-button" type="button">Save</button>
</body>
</html>
tokens.css only defines values, so style the page yourself in site.css:
body {
background: var(--fd-paper);
color: var(--fd-ink);
font-family: var(--fd-font-sans);
}
python3 -m http.server 8000
With a bundler, install the package and import the pieces by name:
npm install fluidither
import 'fluidither/tokens.css';
import 'fluidither/components/tabs.css';
import 'fluidither/components/tabs.js';
Pages load only the files they link, so copying everything costs nothing at runtime. To keep your project tidy as well, name the components instead:
npx fluidither add tabs button gradient
That copies those components, tokens.css and the files they import, then prints the tags to paste. Each component below shows its name. To see them all:
npx fluidither list
Run either command again to add more or to update what you have. --dir works on both.
| Command | What it does |
|---|---|
npx fluidither | Copy the whole library |
npx fluidither add <names> | Copy these components and what they need |
npx fluidither list | Show the component names |
npx fluidither skill | Install the agent skill |
npx fluidither help | Show the commands |
Add the crate, with gpui at the same version:
[dependencies]
fluidither = "0.2"
gpui = { version = "=0.2.2", features = ["runtime_shaders"] }
Call fluidither::init(cx) once at startup. It sets the theme and makes Tab move focus.
use fluidither::{Button, Look, Theme};
use gpui::{div, prelude::*, px, App, Application, Context, Render, Window, WindowOptions};
struct Home;
impl Render for Home {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = Theme::get(cx).colors();
div()
.size_full()
.p(px(24.))
.bg(colors.paper)
.text_color(colors.ink)
.child(Button::new("save", "Save").look(Look::Soft).on_click(|_, _, _| {}))
}
}
fn main() {
Application::new().run(|cx: &mut App| {
fluidither::init(cx);
cx.open_window(WindowOptions::default(), |_, cx| cx.new(|_| Home)).unwrap();
cx.activate(true);
});
}
Building needs no Metal toolchain, because shaders compile at startup. The first build compiles GPUI itself, which took about 10 minutes on an M1 Pro.
Most components are elements. Add them directly as children. They keep their own state, keyed by id, unless you pass the value in. The text field, combobox, command menu and composer are views, so create them once and keep the entity.
The skill teaches a coding agent to use and extend the library: the rules, a reference for each target, recipes for common screens, and how to add to both targets.
npx fluidither skill
That puts it in .agents/skills/fluidither. Add --dir for another place, such as ~/.claude/skills/fluidither for Claude Code.
regular and compact, set on any element.Use a token rather than a hard coded color or size. In a project that copied the source, override the custom properties in your own CSS, and leave the copied files alone.
:root {
--fd-radius-control: 2px;
}
| Use | Tokens |
|---|---|
| Page | --fd-paper, --fd-ink, --fd-ink-soft |
| Surfaces | --fd-surface-0 to --fd-surface-3, each with --fd-shadow-0 to --fd-shadow-3 |
| Lines and fills | --fd-line, --fd-edge, --fd-focus, --fd-soft, --fd-wash, --fd-screen |
| Color | --fd-red, --fd-orange, --fd-green, --fd-blue, --fd-violet, --fd-pink |
| Text | --fd-font-sans, --fd-font-size, --fd-line-height, --fd-weight-regular, --fd-weight-strong |
| Spacing | --fd-gap is 6px, for inside a control or row. --fd-pad is 14px, for cards and page layout |
| Sizes | --fd-control, --fd-segment, --fd-icon, --fd-inset |
| Corners | --fd-radius-control, --fd-radius-pill |
| Motion | --fd-spring-quick, --fd-spring-glide, --fd-spring-settle |
On GPUI, read them from Theme::get(cx).colors() and Density::tokens(). The main colors are paper, ink, ink_soft, line, edge, focus, soft, wash and surfaces[0] to surfaces[3], with the same six named colors.
Themes follow the system. Set data-theme="light" or "dark" on <html> to force one. data-density="compact" works on any element and applies to what is inside it.
On GPUI, set cx.global_mut::<Theme>().mode to Mode::Dark or Mode::Light, then call window.refresh(). Density is Density::Regular or Density::Compact.
There are three: quick for hover, glide for movement, and settle for arrivals. A spring keeps its speed when the target changes, stops by itself and honors reduced motion.
Things leave faster than they arrive. Each spring has an exit a step quicker, quickOut, glideOut and settleOut, or --fd-spring-quick-out and its siblings in CSS. Pass one to glide.to(targets, springs.quickOut) for a single move.
import { Glide, springs } from './fluidither/core/spring.js';
const glide = new Glide({ x: 0 }, springs.glide, ({ x }) => element.style.setProperty('--x', `${x}px`));
glide.to({ x: 120 });
On GPUI, hold a Spring::new(fluidither::generated::GLIDE, 0.0), set target, call step(dt) in render, and call window.request_animation_frame() until it returns true.
One highlight moves between items, in place of each item blinking on and off. Your eye stays on the task, because there is only one thing to follow. It is a dither, so it reads as ink and never as a tint.
Wrap any list. items takes a selector, such as tbody tr, and the children count by default. Disabled items are passed over, and keyboard focus moves the highlight too. nearest keeps the closest item lit across the gaps, measured along axis: y, x or xy. On GPUI, HoverList takes rows of one height.
Children rise into place one after another when they scroll into view, for a page read once. Add subtle for a smaller move. Content stays visible until the script runs, and reduced motion shows it at once. Call .run() to play it again.
First this.
Then this.
Then the last line.
A tone from 0 to 1 is worked out for every cell, then compared with a threshold from a matrix. A cell above its threshold gets ink, so the output is a flat grid of ink and paper that stays crisp at any size.
Matrices are bayer2, bayer4, bayer8, halftone and blue-noise. Bayer looks crisp and patterned, blue noise looks organic, and halftone looks like print. Cells of 2 to 3px read as texture, and larger cells read as pixel art.
A canvas element's ink is its CSS color, and the paper is its background-color. Give gradients, fields and images a size in CSS. To dither a shape of your own, fill a tone array and threshold it:
import { ditherTones, thresholdMap } from './fluidither/core/dither.js';
const map = await thresholdMap('bayer4');
const tones = new Float64Array(width * height).map((_, index) => (index % width) / width);
const cells = new Uint8Array(width * height);
ditherTones(tones, width, height, map, Uint8Array.of(0, 1), cells);
To add a dithered hover to something new:
.thing:hover {
--_dots: color-mix(in srgb, currentColor var(--fd-dither-hover-ink), transparent);
background-image: repeating-conic-gradient(var(--_dots) 0 25%, transparent 0 50%);
background-size: calc(var(--fd-dither-hover-cell) * 2) calc(var(--fd-dither-hover-cell) * 2);
}
The web target has no inline styles and no data: images, so it runs under this policy. This page runs under it too.
default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'
Keep your own code inside it: no style attributes and no <style> tags. Setting styles from JavaScript through the CSSOM is fine. Dither CSS masks built from data URLs are blocked, so use canvas or a repeating-conic-gradient.
A real <button> with a class. Looks are solid, the default, soft, outline and ghost. For a busy button, set aria-busy="true" and disabled. For an icon, put an <svg> inside.
Hover shows a dithered peek at the next tab. Arrow keys, Home and End move the selection, and change fires with detail: { value, index }. Use look="underline" for a lighter form.
A real <input> or <textarea> with a class. Wrap it in .fd-field for a label and a hint, and set aria-invalid="true" for an error. Use a <div class="fd-field"> around a select or combobox, because a label's click doesn't reach them.
Rows that read as one field, split by hairlines, with the label inline. An invalid row colors its label.
Real inputs with a class, in a .fd-choice label.
Rows checked side by side join into one shape, so a selection reads as a whole. It is CSS only.
CheckGroup::new("fruit", ["Apples", "Bananas", "Cherries"]).initial([0, 1]).on_change(|_, _, _| {})A real checkbox with role="switch".
A real range input, with a dithered empty part. After setting its value from code, call .refresh() on the <fd-slider>.
A row of colors, and the browser's color input with a class.
A field of cells for saturation and brightness, a hue slider and the hex to type. Arrow keys move the mark. It fires change and has a .value.
A read only field with a copy button that reports success in words.
Wraps one control. It shows on hover and on keyboard focus. On GPUI, tooltips show on hover only.
Joins forms through name, fires change, and has a .value.
A select you can type into, which filters as you go.
A real <dialog> over a dithered screen. Open it with showModal(), and read returnValue on close. On GPUI, keep an open bool in your view and clear it in on_close.
A dialog that slides in from the edge. Add data-side="bottom" for a sheet.
A searchable list in a modal. Add it once, and call .show() from your own shortcut. pick fires with detail: { value }. The search on this page is one.
Takes value, min and max. It fires change and has a .value.
A calendar in a popup, which joins forms through name.
Any block with .fd-card, holding .fd-card-title and .fd-card-text. Add data-raised to lift it.
Sits on the page with a thin ring.
Lifts off the page for what matters.
Tones are red, orange, green, blue, violet and pink, or none for neutral.
A line of news, with a tone and an optional action. Add <fd-dismiss> to let people close it. It leaves on the fast exit spring and fires dismiss.
Real <details> elements. Give them the same name to keep one open at a time. Animation needs interpolate-size and ::details-content, and without them sections open at once.
A real table. Put data-number on numeric cells.
| Invoice | Client | Amount |
|---|---|---|
| INV-1021 | Harbour Coffee | $1,204.00 |
| INV-1022 | Atlas Print | $98.50 |
Holds an <fd-nav> and collapses to a thin strip. A collapsed attribute holds the state.
A slim bar that takes its colors from the theme. Give it a height.
Version 0.4 added overlays.
Version 0.3 added form controls.
Version 0.2 added tabs.
Version 0.1 added the button.
Before that there was only a dither.
An avatar, carousel dots and an empty state.
ALNew reports will appear here.
Shows the type and size, with no preview, because a thumbnail needs a PDF renderer.
pdfReport.pdf 2.4 MB FileCard::new("report.pdf", "2.4 MB")One key or shortcut, on a real <kbd>.
A page for a bad address.
This page has gone missing.
Give a loader role="status" and a label. Skeletons are decoration, so hide them from assistive tech.
A real <progress>, and a dithered <hr>.
A number with a label and a change, and a small dithered line. Sparklines need an aria-label.
One <i> for each period, with data-state="partial" or "down".
A list with data-state="done" or "active". Without one, a step waits.
Gives any element a soft glow in its data-tone color.
A column of .fd-message, with data-from="me" on the user's, then .fd-thinking while waiting and .fd-composer at the bottom.
Wrap a composer to offer a message. Tab takes the suggestion into an empty box, Enter sends, and Shift and Enter makes a new line. send fires with detail: { text }.
A run of work folded under a line of status, with the sources it used. It is a real <details>.
A <fieldset> that asks one thing, with choices and actions inside.
One question at a time. A click or a number key answers and moves on, while arrow keys choose and Next moves on, so a keyboard is never rushed past an answer. Use checkboxes for several answers. A text input inside an option makes its text the answer. Skip leaves it null. answer fires each time, and done fires with detail: { answers } at the end.
Kinds are bar, area, line, donut, radar, heatmap and gauge. Keep to two or three series, because the shading already adds texture. A gauge's series is value max, a heatmap's series are rows, and a donut takes its slice names from labels.
A hidden table carries the numbers for screen readers. Set the height with --fd-chart-height, not height. The default is 160px. Separate labels with ; if they hold spaces. In a grid, give the track minmax(0, 1fr) or it won't shrink. On GPUI there are no axis labels.
Modes are linear, radial and conic, with angle, center="0.5 0.5", radius, from, to, warp, speed and matrix. It pauses offscreen and holds still under reduced motion.
An animated background computed on the CPU. Kinds are noise, waves, plasma, ripples, tunnel, metaballs, orb, stars, aurora, contours, beams, eclipse, horizon, mesh, grid, ridges, silk and smoke. A speed of 0 draws one still frame. Keep text off busy fields.
A picture redrawn in cells. It must be same origin, so its pixels can be read. Transparent parts stay paper, so it also makes a dithered logo, like the GitHub mark on this page.
A dotted map, or a turning globe with kind="globe". The map comes from outlines drawn by hand, so it looks right but isn't geographic data. Don't use it to show where something really is.
Any drawn element can be saved.
import { savePng } from './fluidither/core/export.js';
savePng(element, 'name.png');
On GPUI, fluidither::export::save_png(path, &cells, columns, rows, 3, ink) writes cells you made yourself, at 3 pixels a cell.
Letters settle from stand in glyphs. Call .run() to play it again, optionally with new text. The real text stays available to assistive tech.
Text appears through a thinning dither. on-view waits until it is seen, and hold="0.1 0.7" keeps the dither, for dithered headings and logos. On a surface other than the page, set --fd-dissolve-paper.
A number that springs to its value. digits sets the decimal places.
A clear band sweeps through a dither over the text, for something at work. words cycles through several. On a surface other than the page, set --fd-dissolve-paper.
Two images or dither elements with a real range input for the split, so it works with the keyboard. Drag across it.
A strip that moves on its own and pauses on hover and focus.
Use .fd-card sections holding .fd-field rows, with .fd-switch for on and off and <fd-select> for choices. Put a solid button last, with a soft one for cancel. Use data-density="compact" on the page if there's a lot of it.
A row of cards, each with a .fd-stat and an <fd-sparkline>, above one large <fd-chart> and a .fd-table. Use color for series, and tones for status with .fd-badge.
<fd-dither-gradient class="band" angle="90" from="0" to="0.75" speed="0.4"></fd-dither-gradient>
.band {
height: 120px;
border-radius: 8px;
}
Use <fd-field kind="aurora"> for something livelier.
Add <fd-command> once, and call .show() from your own shortcut. On GPUI, create a CommandMenu view, add it as a child, and call show.
| Where | Status |
|---|---|
| Chromium | Tested. 136 browser tests run under the strict CSP, with axe in both themes. |
| macOS on Apple Silicon | Tested with Rust 1.98.1 and gpui 0.2.2. |
| Firefox, Safari | Not run. Styles for them are written but have never been seen. linear() easing needs Safari 17.2 or later. |
| Linux, Windows | Not built for GPUI. |
| Screen readers on the web | Real elements and ARIA, but not tried with a real screen reader. |
| Screen readers on GPUI | Not supported. gpui 0.2.2 has no accessibility API. |
Vertical tabs, sortable tables, submenus and PDF previews are missing on both targets. GPUI also has no multi line text and no link style button. The full list of limits is on GitHub.
| What | Gzipped |
|---|---|
tokens.css, once per page | 1.2 KB |
| Most CSS only components | under 1 KB each |
| Tokens plus an animated gradient | 4.2 KB |
| The overlays, such as Select and the command menu | 3 to 5 KB each |
| Every file, compressed separately | about 65 KB |
The last figure counts shared code more than once. There are no runtime dependencies. The frame costs for both targets are on GitHub.