fluidither

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.

Install for the web

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';

Take only what you need

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.

The commands
CommandWhat it does
npx fluiditherCopy the whole library
npx fluidither add <names>Copy these components and what they need
npx fluidither listShow the component names
npx fluidither skillInstall the agent skill
npx fluidither helpShow the commands

Install for GPUI

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.

GPUI 0.2.2 has no accessibility API, so screen readers can't see these components. Don't use this target alone for anything that must be accessible.

Agent skill

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.

Design

  • Ink and paper. Warm neutrals, with color kept for meaning. Dark mode is light ink on dark paper, not an inverted page.
  • Dither instead of tint. Hover states, dialog screens, chart shading and the empty part of a slider are real dithers, drawn cell by cell. Never put a dither behind body text.
  • Springs instead of durations. Motion keeps its speed when the target changes, and reduced motion is respected.
  • Pixel marks. Ticks, dashes and arrows are small cell drawings from the tokens, so they match on both targets.
  • Two densities. regular and compact, set on any element.

Tokens

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;
}
Web tokens
UseTokens
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 and density

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.

Springs

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.

Gliding hover

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.

Hover

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.

Reveal

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.

Reveal

Dithers

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);
}

Strict CSP

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.

Actions

Button

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.

Tabs

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.

Forms

Text field

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.

Input group

Rows that read as one field, split by hairlines, with the label inline. An invalid row colors its label.

Checkbox and radio

Real inputs with a class, in a .fd-choice label.

Check group

Rows checked side by side join into one shape, so a selection reads as a whole. It is CSS only.

Switch

A real checkbox with role="switch".

Slider

A real range input, with a dithered empty part. After setting its value from code, call .refresh() on the <fd-slider>.

Swatches and color

A row of colors, and the browser's color input with a class.

Color picker

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.

Copy field

A read only field with a copy button that reports success in words.

Overlays

Tooltip

Wraps one control. It shows on hover and on keyboard focus. On GPUI, tooltips show on hover only.

Select

Joins forms through name, fires change, and has a .value.

Combobox

A select you can type into, which filters as you go.

Dialog

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.

Drawer and sheet

A dialog that slides in from the edge. Add data-side="bottom" for a sheet.

Command menu

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.

Dates

Calendar

Takes value, min and max. It fires change and has a .value.

Date picker

A calendar in a popup, which joins forms through name.

Structure

Card

Any block with .fd-card, holding .fd-card-title and .fd-card-text. Add data-raised to lift it.

Badge

Tones are red, orange, green, blue, violet and pink, or none for neutral.

Accordion

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.

Table

A real table. Put data-number on numeric cells.

Scroll area

A slim bar that takes its colors from the theme. Give it a height.

Small pieces

An avatar, carousel dots and an empty state.

File card

Shows the type and size, with no preview, because a thumbnail needs a PDF renderer.

Key

One key or shortcut, on a real <kbd>.

Missing page

A page for a bad address.

Feedback

Loader and skeleton

Give a loader role="status" and a label. Skeletons are decoration, so hide them from assistive tech.

Progress and divider

A real <progress>, and a dithered <hr>.

Stat and sparkline

A number with a label and a change, and a small dithered line. Sparklines need an aria-label.

Uptime

One <i> for each period, with data-state="partial" or "down".

Steps

A list with data-state="done" or "active". Without one, a step waits.

Glow

Gives any element a soft glow in its data-tone color.

Chat

Messages and composer

A column of .fd-message, with data-from="me" on the user's, then .fd-thinking while waiting and .fd-composer at the bottom.

Suggested replies

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 }.

Thinking steps

A run of work folded under a line of status, with the sources it used. It is a real <details>.

Question card

A <fieldset> that asks one thing, with choices and actions inside.

Question flow

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.

Charts

Charts

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.

Dither

Gradient

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.

Field

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.

Image

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.

Map and globe

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.

Save as PNG

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.

Text

Scramble

Letters settle from stand in glyphs. Call .run() to play it again, optionally with new text. The real text stays available to assistive tech.

Dissolve

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.

Count

A number that springs to its value. digits sets the decimal places.

Shimmer

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.

Media

Compare

Two images or dither elements with a real range input for the split, so it works with the keyboard. Drag across it.

Marquee

A strip that moves on its own and pauses on hover and focus.

Recipes

A settings page

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 dashboard

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.

A hero band

<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.

A command palette

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.

Support

WhereStatus
ChromiumTested. 136 browser tests run under the strict CSP, with axe in both themes.
macOS on Apple SiliconTested with Rust 1.98.1 and gpui 0.2.2.
Firefox, SafariNot run. Styles for them are written but have never been seen. linear() easing needs Safari 17.2 or later.
Linux, WindowsNot built for GPUI.
Screen readers on the webReal elements and ARIA, but not tried with a real screen reader.
Screen readers on GPUINot 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.

Performance

Bundle size on the web
WhatGzipped
tokens.css, once per page1.2 KB
Most CSS only componentsunder 1 KB each
Tokens plus an animated gradient4.2 KB
The overlays, such as Select and the command menu3 to 5 KB each
Every file, compressed separatelyabout 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.