Halftone UI
A component library that ships its own printing press. This wordmark is not a font rendering — it is four ink plates at the true process screen angles, each dot sized by the tone underneath it. Every surface below is pressed by the same engine.
Quick start
A halftone UI toolkit — charts, primitives and app components on one seed-generative
point-cloud engine. The engine is plain TypeScript with no framework in it; @halftone-ui/vue
and @halftone-ui/react are thin adapters over the same core.
# npm i @halftone-ui/vue <script setup> import { HButton, HMeter } from "@halftone-ui/vue" </script> <template> <HButton color="purple">Create account</HButton> <HMeter label="Memory" :value="68" /> </template>
# npm i @halftone-ui/react import { Button, Meter } from "@halftone-ui/react" export function Panel() { return ( <> <Button color="purple">Create account</Button> <Meter label="Memory" value={68} /> </> ) }
Every visual surface is a canvas driven by tone(x, y, t) → 0..1 evaluated over a seeded blue-noise point cloud — a point is inked when tone beats its threshold. Components are the real DOM (buttons, inputs, tablists); the halftone is a decorative layer painted behind them, so semantics and accessibility come for free.
Styling
Seven palette colors — green blue purple pink orange red grey — plus
ink and surface tokens. Pass color to any component; density and dot radius are theme
tokens shared by every surface.
Seeds
Every point cloud derives from one integer seed. Reroll it (topbar, or
provider.reroll()) and every surface on the page regenerates its identity —
same data, new grain. Components take a seedOffset so neighbors never share a cloud.
Accessibility
Canvases are aria-hidden decoration; interaction lives on native
elements underneath (real <button>s, roving-tabindex tablists,
aria-pressed toggles). Values are always rendered as text, never only as density.
prefers-reduced-motion disables drift, blooms and entrance animation.
Composition
Components are plain markup wrapping a canvas. Parts render in template order, and template order is paint order — whatever is pressed last sits on top. No portals, no z-index arms race.
workspace
<HCard> <HBadge color="green">stable</HBadge> <HMeter label="Usage" :value="64" /> <HButton color="purple" block>Deploy</HButton> </HCard>
<Card> <Badge color="green">stable</Badge> <Meter label="Usage" value={64} /> <Button color="purple" block>Deploy</Button> </Card>
Motion
Surfaces never tween CSS — they re-press. State glides a value, every dot re-tests its threshold against it, and the grain itself is the animation. Honors prefers-reduced-motion by jumping straight to the final frame.
<HWash color="pink" :reveal="revealed" /> <HButton color="pink" @click="replay">Replay</HButton>
<Wash color="pink" reveal={revealed} /> <Button color="pink" onClick={replay}>Replay</Button>
| Prop | Type | Default |
|---|---|---|
| reveal | number 0..1 | 1 |
| glide | number (easing k) | 0.16 |
Examples
Sign up
A signup card with a working plan picker — segmented control from plain aria-pressed buttons, purple wash behind.
<HCard wash="purple"> <HInput v-model="name" placeholder="Ada Byte" /> <HSegmented v-model="plan" :options="['Free','Pro','Scale']" /> <HButton color="purple" block>Create account</HButton> </HCard>
<Card wash="purple"> <Input value={name} onChange={setName} placeholder="Ada Byte" /> <Segmented value={plan} onChange={setPlan} options={["Free", "Pro", "Scale"]} /> <Button color="purple" block>Create account</Button> </Card>
Monitoring
A static gauge on the progress track — the fill picks its own color: green below the low mark, orange between, red above the high mark.
<HMeter label="Disk" :value="35" /> <HMeter label="Memory" :value="68" /> <HMeter label="CPU" :value="92" />
<Meter label="Disk" value={35} /> <Meter label="Memory" value={68} /> <Meter label="CPU" value={92} />
Dashboard
Stat tiles, sparklines, and capacity gauges — the whole overview page pressed from one seed.
capacity
<HStat label="requests/min" value="4,812" :trend="reqs" /> <HStat label="uptime, 30d" value="99.98%" :trend="uptime" /> <HMeter label="Disk" :value="41" />
<Stat label="requests/min" value="4,812" trend={reqs} /> <Stat label="uptime, 30d" value="99.98%" trend={uptime} /> <Meter label="Disk" value={41} />
App shell
Sidebar, topbar, content well. The shell is chrome-only — every surface inside stays a canvas you already know.
Team
A members table where state rides on badges, not colored text.
| Member | Role | Status | Last active |
|---|---|---|---|
| Ada Byte | Owner | online | now |
| Grace Point | Maintainer | online | 2m ago |
| Alan Turing-Test | Member | away | 1h ago |
| Linus Kernel | Reviewer | busy | 3h ago |
<HTable :rows="members"> <template #status="{ row }"> <HBadge :color="row.color">{{ row.status }}</HBadge> </template> </HTable>
<Table rows={members} columns={[
{ key: "status", cell: (row) => <Badge color={row.color}>{row.status}</Badge> },
]} />
Usage & billing
Metered usage against plan limits, and the invoices to prove it.
this cycle · Pro plan
| Invoice | Amount | Status |
|---|---|---|
| June 2026 | $49.00 | paid |
| May 2026 | $49.00 | paid |
| April 2026 | $42.50 | paid |
<HMeter label="API calls" :value="62" caption="6.2M / 10M" /> <HTable :rows="invoices" />
<Meter label="API calls" value={62} caption="6.2M / 10M" /> <Table rows={invoices} />
Sign in
The return trip: same card as sign up, quieter ask, blue wash.
<HCard wash="blue"> <HInput v-model="email" type="email" /> <HInput v-model="password" type="password" /> <HButton color="blue" block>Sign in</HButton> </HCard>
<Card wash="blue"> <Input value={email} onChange={setEmail} type="email" /> <Input value={password} onChange={setPassword} type="password" /> <Button color="blue" block>Sign in</Button> </Card>
Magic link
One field, no password. The button does the remembering.
<HCard wash="green"> <HInput v-model="email" type="email" /> <HButton color="green" block>Email me a link</HButton> </HCard>
<Card wash="green"> <Input value={email} onChange={setEmail} type="email" /> <Button color="green" block>Email me a link</Button> </Card>
Two-factor
Six boxes, auto-advance, backspace walks backwards. The OTP field from the primitives, dressed for the door.
<HCard wash="orange"> <HOtpField v-model="code" :length="6" /> <HButton color="orange" block>Verify</HButton> </HCard>
<Card wash="orange"> <OtpField value={code} onChange={setCode} length={6} /> <Button color="orange" block>Verify</Button> </Card>
Pricing
Three cards; the accent border does the selling, the grain does the texture.
Free
- 1 project
- Community seeds
- MIT license
Pro
- Unlimited projects
- Ink styles
- Priority press
Scale
- SSO + audit log
- Custom plates
- Support SLA
<HPricingCard v-for="plan in plans" :key="plan.name" :plan="plan" :featured="plan.name === 'Pro'" />
{plans.map((plan) => (
<PricingCard key={plan.name} plan={plan} featured={plan.name === "Pro"} />
))}
Activity feed
A pressed dot per event — color is the event type, time keeps to the margin.
<HFeed :items="events"> <template #item="{ event }"> <HDot :color="event.color" /> {{ event.text }} </template> </HFeed>
<Feed items={events} renderItem={(event) => (
<><Dot color={event.color} /> {event.text}</>
)} />
Changelog
Versions as badges, changes as sentences. Herald would approve.
Ink styles ship: four-plate crosshatch on any component via the inks prop.
New OKLCH theme wheel — rotate every pigment at once.
Charts: line, pie, and radar join area and bars.
Seeded Poisson clouds became deterministic across SSR and client.
<HChangelog :releases="releases" />
<Changelog releases={releases} />
Charts
Area Chart
Children-as-config, like the reference kit — compose parts inside a sized root. Hover for the crosshair and tooltip; click a legend entry to isolate a series.
data table
<AreaChart :data="data" :config="config"> <Grid /> <XAxis dataKey="month" /> <YAxis /> <Area dataKey="desktop" /> <Area dataKey="mobile" /> <Legend /> <Tooltip labelKey="month" /> </AreaChart>
<AreaChart data={data} config={config}>
<Grid />
<XAxis dataKey="month" />
<YAxis />
<Area dataKey="desktop" />
<Area dataKey="mobile" />
<Legend />
<Tooltip labelKey="month" />
</AreaChart>
Bar Chart
Tone thresholding against the same point cloud; hover a bar for bloom and its value.
<BarChart :data="deploys" :config="config"> <XAxis dataKey="week" /> <Bar dataKey="count" is-clickable /> </BarChart>
<BarChart data={deploys} config={config}>
<XAxis dataKey="week" />
<Bar dataKey="count" isClickable />
</BarChart>
Sparkline
A stat tile with a halftone trace — number first, grain second.
<Sparkline :data="reqs" color="blue" />
<Sparkline data={reqs} color="blue" />
Line Chart
One series, one stroke — the line is solid ink and a comet of grain falls away beneath it.
<HLineChart :data="requests" color="blue" />
<LineChart :data="requests" color="blue" />
| Prop | Type | Default |
|---|---|---|
| data | number[] | — |
| color | HalftoneColor | "blue" |
| falloff | number | 0.5 |
Pie Chart
Four plates stacked on one ring — each slice is its own canvas, so each keeps its own ink.
<HPieChart :slices="[42, 25, 19, 14]" :colors="['blue', 'orange', 'green', 'grey']" />
<PieChart slices={[42, 25, 19, 14]}
colors={["blue", "orange", "green", "grey"]} />
Radar
A pentagon web drawn in hairline, filled with grain that thickens toward the rim.
<HRadarChart :axes="['speed', 'a11y', 'dx', 'size', 'ssr']" :values="[84, 58, 74, 46, 68]" color="purple" />
<RadarChart axes={["speed", "a11y", "dx", "size", "ssr"]}
values={[84, 58, 74, 46, 68]} color="purple" />
Primitives
Button
Halftone fill on a native button; hover blooms the grain. Outline variant keeps a sparse wash.
<HButton color="green">Save</HButton> <HButton color="red">Export</HButton> <HButton color="grey" variant="outline">Cancel</HButton>
<Button color="green">Save</Button> <Button color="red">Export</Button> <Button color="grey" variant="outline">Cancel</Button>
| Prop | Type | Default |
|---|---|---|
| color | HalftoneColor | "grey" |
| variant | "fill" | "outline" | "fill" |
| block | boolean | false |
Badge
A quiet wash chip in any palette color.
<HBadge color="green">stable</HBadge>
<Badge color="green">stable</Badge>
Tabs
A roving-tabindex tablist with a pressed underline that slides to the active tab. Arrow keys move selection.
<HTabs v-model="tab" :tabs="['Overview','Metrics','Logs']" />
<Tabs value={tab} onChange={setTab} tabs={["Overview", "Metrics", "Logs"]} />
Variants
Vertical · badges · disabled
| Prop | Type | Default |
|---|---|---|
| tabs | (string | { value, label?, badge?, disabled? })[] | — |
| modelValue | string | — |
| variant | "underline" | "segmented" | "washed" | "underline" |
| vertical | boolean | false |
Meter
The gauge from the Monitoring example, on its own. Color resolves automatically from the low/high marks unless pinned.
<HMeter label="Build cache" :value="54" :low="40" :high="80" />
<Meter label="Build cache" value={54} low={40} high={80} />
| Prop | Type | Default |
|---|---|---|
| value | number | — |
| low | number | 40 |
| high | number | 80 |
| color | HalftoneColor | auto |
Toast
Imperative, like every toast — but the wash behind it is the same engine.
import { toast } from "@halftone-ui/vue" toast("Deploy finished", { color: "blue", duration: 3500 })
import { toast } from "@halftone-ui/react" toast("Deploy finished", { color: "blue", duration: 3500 })
| Prop | Type | Default |
|---|---|---|
| message | string | — |
| opts.color | HalftoneColor | "blue" |
| opts.duration | number | 3500 |
Avatar
Seed-generative identicons — symmetric noise, screened. Click one to reroll it.
<HAvatar :seed="user.id" />
<Avatar seed={user.id} />
Skeleton
Loading placeholders whose shimmer is a density wave traveling through the grain.
<HSkeleton :lines="3" />
<Skeleton lines={3} />
Gradient
A wash — seeded value-noise drifting in time. Use it behind cards and heroes.
<HGradient color="purple" drift />
<Gradient color="purple" drift />
Image
Any raster becomes tone: luminance drives the threshold field, so the photo is re-printed in dots of the current ink. Retheme and it re-inks itself.
<HImage src="/eric.jpg" :grain="1.9" />
<Image src="/eric.jpg" :grain="1.9" />
| Prop | Type | Default |
|---|---|---|
| src | string | — |
| grain | number (px) | 2.2 |
| gamma | number | 1.3 |
Switch
The track floods with grain as it switches on; the thumb is plain CSS riding above the press.
<HSwitch v-model="notify" color="green" />
<Switch checked={notify} onCheckedChange={setNotify} color="green" />
| Prop | Type | Default |
|---|---|---|
| modelValue | boolean | false |
| color | HalftoneColor | "green" |
Checkbox
The box fills left-to-right like a pen stroke; the tick is knocked out in paper color.
<HCheckbox v-model="email">Email me on deploy</HCheckbox>
<Checkbox checked={email} onCheckedChange={setEmail}>Email me on deploy</Checkbox>
Checkbox Group
Independent checks that report as one array.
<HCheckboxGroup v-model="targets"> <HCheckbox value="vue">Vue</HCheckbox> <HCheckbox value="react">React</HCheckbox> <HCheckbox value="svelte">Svelte</HCheckbox> </HCheckboxGroup>
<CheckboxGroup value={targets} onChange={setTargets}>
<Checkbox value="vue">Vue</Checkbox>
<Checkbox value="react">React</Checkbox>
<Checkbox value="svelte">Svelte</Checkbox>
</CheckboxGroup>
Radio
One dot of ink at a time.
<HRadioGroup v-model="density" :options="['Comfortable', 'Compact', 'Dense']" />
<RadioGroup value={density} onChange={setDensity}
options={["Comfortable", "Compact", "Dense"]} />
Toggle
A pressed-state button: on means inked.
<HToggle v-model="fav">★ Favorite</HToggle>
<Toggle pressed={fav} onPressedChange={setFav}>★ Favorite</Toggle>
Toggle Group
Exclusive pressure — one plate down at a time.
<HToggleGroup v-model="align" :options="['Left', 'Center', 'Right']" />
<ToggleGroup value={align} onChange={setAlign}
options={["Left", "Center", "Right"]} />
Slider
A native range input over a halftone fill — grain pours in behind the thumb.
<HSlider v-model="volume" color="blue" />
<Slider value={volume} onChange={setVolume} color="blue" />
| Prop | Type | Default |
|---|---|---|
| modelValue | number | 0 |
| min / max / step | number | 0 / 100 / 1 |
| color | HalftoneColor | "blue" |
Progress
Determinate takes a value; indeterminate breathes on a sine and never lies about being done.
<HProgress indeterminate color="purple" />
<Progress indeterminate color="purple" />
| Prop | Type | Default |
|---|---|---|
| value | number | undefined | undefined |
| indeterminate | boolean | false |
Spinner
A comet of ink chasing its tail. Density falls off behind the head, like a nib running dry.
<HSpinner color="blue" />
<Spinner color="blue" />
Input
A plain input on card paper. Focus swaps the border to the accent — no glow, no ring, just ink.
<HInput v-model="email" label="Email" type="email" />
<Input value={email} onChange={setEmail} label="Email" type="email" />
Textarea
Same paper, more room.
<HTextarea v-model="notes" label="Release notes" :rows="4" />
<Textarea value={notes} onChange={setNotes} label="Release notes" rows={4} />
Field
Label, control, and hint stacked with one rhythm. Errors swap the hint, not the layout.
<HField label="Display name" hint="Shown on your public profile."> <HInput v-model="name" /> </HField>
<Field label="Display name" hint="Shown on your public profile."> <Input value={name} onChange={setName} /> </Field>
Fieldset
A drawn border and a legend — the oldest grouping primitive on the web, kept.
<HFieldset legend="Notifications"> <HCheckbox v-model="deploys">Deploy succeeded</HCheckbox> <HCheckbox v-model="digest">Weekly digest</HCheckbox> </HFieldset>
<Fieldset legend="Notifications">
<Checkbox checked={deploys} onCheckedChange={setDeploys}>Deploy succeeded</Checkbox>
<Checkbox checked={digest} onCheckedChange={setDigest}>Weekly digest</Checkbox>
</Fieldset>
Form
Fields compose into a form; submit is just a button that means it.
<HForm @submit="create"> <HInput v-model="project" label="Project" /> <HSelect v-model="region" label="Region" :options="regions" /> <HButton color="green" type="submit" block>Create project</HButton> </HForm>
<Form onSubmit={create}>
<Input value={project} onChange={setProject} label="Project" />
<Select value={region} onChange={setRegion} label="Region" options={regions} />
<Button color="green" type="submit" block>Create project</Button>
</Form>
Number Field
Steppers on either side; the input stays honest numeric.
<HNumberField v-model="seats" :min="0" />
<NumberField value={seats} onChange={setSeats} min={0} />
OTP Field
Auto-advance on entry, backspace walks back. Paste-friendly, digit-only.
<HOtpField v-model="code" :length="6" />
<OtpField value={code} onChange={setCode} length={6} />
Select
The native select, dressed in card paper. No re-implemented listbox to break your screen reader.
<HSelect v-model="region" label="Region" :options="regions" />
<Select value={region} onChange={setRegion} label="Region" options={regions} />
Combobox
Type to filter, click to pick. The list is plain markup that hides itself.
- Vue
- React
- Svelte
- Solid
- Angular
<HCombobox v-model="fw" :options="frameworks" />
<Combobox value={fw} onChange={setFw} options={frameworks} />
Autocomplete
Combobox that keeps free text — suggestions help, they never gate.
- Amsterdam
- Austin
- Berlin
- Boston
- Lisbon
- Tokyo
<HAutocomplete v-model="city" :suggestions="cities" />
<Autocomplete value={city} onChange={setCity} suggestions={cities} />
Kbd
Keycaps set in the mono stack with a printed edge.
Press ⌘ K to open the command menu, ? for shortcuts, and esc to put everything back.
Press <HKbd>⌘</HKbd> <HKbd>K</HKbd> to open the command menu.
Press <Kbd>⌘</Kbd> <Kbd>K</Kbd> to open the command menu.
Separator
A rule inked by hand — density breathes along its length instead of a flat 1px line.
Above the fold
Below it
<HSeparator />
<Separator />
Collapsible
A native details element — open state is real, keyboardable, and free.
Advanced options
<HCollapsible label="Advanced options">
Seed pinning, plate order, misregistration.
</HCollapsible>
<Collapsible label="Advanced options">
Seed pinning, plate order, misregistration.
</Collapsible>
Accordion
Stacked collapsibles sharing hairline borders.
Why canvas and not SVG?
Does it SSR?
What about print?
<HAccordion :items="faq" />
<Accordion :items="faq" />
Dialog
The native dialog element with showModal — focus trapping and the backdrop come free.
<HDialog v-model:open="open" title="Rename workspace"> This changes the URL of every published board. <template #footer> <HButton variant="outline" @click="open = false">Cancel</HButton> <HButton color="blue" @click="rename">Rename</HButton> </template> </HDialog>
<Dialog open={open} onOpenChange={setOpen} title="Rename workspace">
This changes the URL of every published board.
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button color="blue" onClick={rename}>Rename</Button>
</DialogFooter>
</Dialog>
Alert Dialog
A dialog that means it. Red ink, verb-first button, no soft exits.
<HAlertDialog v-model:open="open" tone="danger" title="Delete ink-press?" confirm="Delete forever" @confirm="destroy" />
<AlertDialog open={open} onOpenChange={setOpen} tone="danger"
title="Delete ink-press?" confirm="Delete forever" onConfirm={destroy} />
Drawer
The same native dialog, parked against the right edge.
<HDrawer v-model:open="open" side="right" title="Press settings"> <HInput v-model="seed" label="Seed" /> </HDrawer>
<Drawer open={open} onOpenChange={setOpen} side="right" title="Press settings">
<Input value={seed} onChange={setSeed} label="Seed" />
</Drawer>
Popover
A summary/details pair — anchored, dismissable, zero JavaScript of its own.
<HPopover label="Share"> Anyone with the link can view this board. <HButton size="sm">Copy link</HButton> </HPopover>
<Popover label="Share"> Anyone with the link can view this board. <Button size="sm">Copy link</Button> </Popover>
Tooltip
Hover or focus; pure CSS reveal. Underlined-dashed marks the invitation.
Every surface has a grainAverage dot pitch in CSS px — smaller is finer. and a seedThe integer that makes the cloud reproducible..
<HTooltip text="Average dot pitch in CSS px.">grain</HTooltip>
<Tooltip text="Average dot pitch in CSS px.">grain</Tooltip>
Preview Card
A link that shows its hand on hover.
Built with @halftone-uihalftone-uiThe component library that ships its own printing press. MIT, tree-shakeable, one canvas per part.stable and stubbornness.
<HPreviewCard href="https://github.com/ecgang/halftone-ui">
@halftone-ui
</HPreviewCard>
<PreviewCard href="https://github.com/ecgang/halftone-ui">
@halftone-ui
</PreviewCard>
Toolbar
Grouped toggles with a hairline divider — pressed means inked.
<HToolbar> <HToggle v-model="bold">B</HToggle> <HToggle v-model="italic">I</HToggle> <HSeparator vertical /> <HButton variant="ghost">Link</HButton> </HToolbar>
<Toolbar>
<Toggle pressed={bold} onPressedChange={setBold}>B</Toggle>
<Toggle pressed={italic} onPressedChange={setItalic}>I</Toggle>
<Separator vertical />
<Button variant="ghost">Link</Button>
</Toolbar>
Breadcrumb
The trail home. The last crumb is text, not a link — you are already there.
<HBreadcrumb :items="['Home', 'Projects', 'ink-press']" />
<Breadcrumb items={["Home", "Projects", "ink-press"]} />
Pagination
Numbered pages in hairline boxes; the current one takes the accent.
<HPagination v-model="page" :total="4" />
<Pagination page={page} onChange={setPage} total={4} />
Sidebar
The app-shell nav as its own primitive — sections, current-page fill, nothing clever.
Scroll Area
A bounded well with a thin, themed scrollbar — native scrolling underneath.
The press does not care how long the copy runs; the well stays put and the paper scrolls.
Poisson-disk sampling guarantees no two dots crowd each other, which is why the grain reads as texture instead of noise.
Every surface holds its seed, so a scrollback looks identical to the first printing.
And when you reroll, everything rerolls together — one seed to rule the page.
<HScrollArea :height="150">…</HScrollArea>
<ScrollArea :height="150">…</ScrollArea>
Rating
Five stars pressed from the same star field as the Marks section — click to re-ink.
<HRating v-model="stars" :max="5" />
<Rating value={stars} onChange={setStars} max={5} />
Stepper
Numbered stations; completed steps trade their number for a tick.
<HStepper v-model="step" :steps="['Account', 'Workspace', 'Invite']" />
<Stepper value={step} onChange={setStep}
steps={["Account", "Workspace", "Invite"]} />
Timeline
Events on a hairline rail, each anchored by a pressed dot.
v0.4.0 shipped
Ink styles and the OKLCH wheel went out together.
Charts landed
Line, pie, and radar joined the family.
First commit
One canvas, one seed, ten thousand dots.
<HTimeline :events="history" />
<Timeline :events="history" />
Ink styles
Ink styles
A second rendering mode for every component — print screens instead of the halftone
field. Pass inks to any component and its surface becomes a two-ink press run: each ink
gets a tonal band and its own screen angle, dots grow past their pitch into Euclidean merge blobs,
and the layers composite with the plastisol blend — halfway between opaque paint and transparent
process ink.
Ink surfaces print on paper (light or dark), inks stack in array order
— the press sequence, so ['blue','orange'] ≠ ['orange','blue'] — and
pattern="hatch" spreads the pair across four angled screens: ink B, ink A, their
multiplied mix, and a key ink. On dark paper coverage follows lightness and inks print opaque, like
screen-printing a black tee. Type is never screened — text knocks out to paper, sharp.
| Prop | Type | Default |
|---|---|---|
| inks | [InkColor, InkColor] — press order; enables ink mode | — |
| pattern | "stipple" | "lines" | "waves" | "hatch" | "am" | "stipple" |
| body | number 0–1 — 0 opaque paint · 1 transparent ink | 0.55 |
| misreg | number (px of plate misregistration) | 1 |
| scale | number (screen pitch, px) | 6 |
| paper | "light" | "dark" | "light" |
Studio
Every ink prop, live. Dial the press — pattern, ink pair, press order, plastisol body, plate misregistration, screen pitch, paper — on a test plate: a tonal ramp with an orb, so each screen's band is visible as it joins. plates: tuned is the harmony pass: both inks are pulled into one lightness band so neither dominates, and the mix plate's multiply is lifted into a rich overprint instead of mud. The prop line underneath is the exact component call you'd ship.
<HInkPanel pattern="hatch" :inks="['blue','orange']" :body="0.7" :misreg="1" :scale="3.4" paper="light" />
<InkPanel pattern="hatch" inks={['blue','orange']} body={0.7} misreg={1} scale={3.4} paper="light" />
Sign in
An auth card washed by the press — the card is an HInkPanel on dark
paper, so the four-screen crosshatch prints opaque and keys white toward the base. The form stays
plain; the CTA is an HButton running the same two inks.
<HInkPanel pattern="hatch" :inks="['purple','pink']" paper="dark" :misreg="1.2"> <HInput v-model="email" type="email" /> <HInput v-model="password" type="password" /> <HButton pattern="hatch" :inks="['purple','pink']" :body="0.7" block>Sign in</HButton> </HInkPanel>
<InkPanel pattern="hatch" inks={['purple','pink']} paper="dark" misreg={1.2}> <Input value={email} onChange={setEmail} type="email" /> <Input value={password} onChange={setPassword} type="password" /> <Button pattern="hatch" inks={['purple','pink']} body={0.7} block>Sign in</Button> </InkPanel>
Area Chart
The same HAreaChart with inks — the fill under the curve
becomes the four-screen crosshatch. Depth stacks the plates: one screen at the line, blue crossing
orange in the middle, their mix and the black key by the baseline. A printed chart, not a chart
with a texture on it.
<HAreaChart :data="revenue" pattern="hatch" :inks="['green','blue']" :body="0.7" :misreg="1" :scale="3.2" />
<AreaChart data={revenue} pattern="hatch" inks={['green','blue']}
body={0.7} misreg={1} scale={3.2} />
Bar Chart
Crosshatch in the bars — each bar of an HBarChart is its own little
press run: one screen at the top, the plates stacking as the bar falls toward the baseline, the
tall bars reaching the black key. Value is encoded twice — height and ink.
<HBarChart :data="deploys" pattern="hatch" :inks="['red','black']" :body="0.7" :misreg="0.8" :scale="3" />
<BarChart data={deploys} pattern="hatch" inks={['red','black']}
body={0.7} misreg={0.8} scale={3} />
Line Chart
An HLineChart run through the press — the inverse of the area chart.
The stroke itself is the darkest pass, all four plates on the line, and the ink hangs off it
like a comet tail: lighter screens falling away beneath, bare paper above.
<HLineChart :data="latency" pattern="hatch" :inks="['pink','blue']" :body="0.65" :misreg="0.9" :scale="2.8" />
<LineChart data={latency} pattern="hatch" inks={['pink','blue']}
body={0.65} misreg={0.9} scale={2.8} />
Donut
An HDonut gauge as a press run — the plates stack along the
arc. One screen where the ring begins, the mix midway, the black key arriving right at the head
of the progress. The remainder track is a whisper of the first screen.
<HDonut :value="0.72" pattern="hatch" :inks="['red','yellow']" :body="0.65" :misreg="0.9" :scale="3" />
<Donut value={0.72} pattern="hatch" inks={['red','yellow']}
body={0.65} misreg={0.9} scale={3} />
Heatmap
An HHeatmap — the contribution-graph grid, printed. Every cell is a
tiny plate proof: quiet weeks take a single screen, busy weeks stack toward the key. Value maps
to press depth instead of a color ramp, so the whole grid stays two inks.
<HHeatmap :data="commits" pattern="hatch" :inks="['blue','orange']" :body="0.65" :misreg="0.7" :scale="2.8" />
<Heatmap data={commits} pattern="hatch" inks={['blue','orange']}
body={0.65} misreg={0.7} scale={2.8} />
Ink Button
The same HButton — inks switches its fill to a press run.
Hover leans on the ink. The last one prints opaque on dark paper, black-tee style.
<HButton pattern="waves" :inks="['blue','orange']">Print</HButton> <HButton pattern="lines" :inks="['purple','pink']" :body="0.5">Preview</HButton> <HButton pattern="hatch" :inks="['blue','orange']" :body="0.7">Overprint</HButton> <HButton pattern="waves" :inks="['orange','blue']" paper="dark">Black tee</HButton>
<Button pattern="waves" inks={['blue','orange']}>Print</Button> <Button pattern="lines" inks={['purple','pink']} body={0.5}>Preview</Button> <Button pattern="hatch" inks={['blue','orange']} body={0.7}>Overprint</Button> <Button pattern="waves" inks={['orange','blue']} paper="dark">Black tee</Button>
Ink Meter
A meter whose fill is the crosshatch — the plates stack toward the value, the empty track keeps a faint ink dust.
<HMeter label="Coverage" :value="72" pattern="hatch" :inks="['purple','pink']" />
<Meter label="Coverage" value={72} pattern="hatch" inks={['purple','pink']} />
Marks
Printed sprites — an HInkMark takes a shape and runs it through the
same press. The glyph is just a field: light at the crown, all four plates by the foot, so every
icon carries the crosshatch. Deterministic, seeded, and re-inkable like everything else.
<HInkMark shape="bolt" :inks="['blue','orange']" :size="58" /> <HInkMark shape="star" :inks="['red','black']" :size="58" />
<InkMark shape="bolt" inks={['blue','orange']} size={58} /> <InkMark shape="star" inks={['red','black']} size={58} />
Comic cover
Case study — ericgang.com as a pulp cover. An HInkImage
samples a photograph's luminance as the field, so the press crosshatches a real face the way a
1950s cover artist would: highlights stay paper, midtones take one or two screens, shadows stack
to the key. The photo is pulled straight off the site; the type is plain DOM. Hover it — it's
headed for a 3D shelf.
№ 1
ERIC GANG
AS
THE FULL-STACK VISITOR
FROM THE
TERMINAL
<HInkImage src="/assets/eric-gang.jpg" pattern="hatch" :inks="['green','orange']" :body="0.62" :misreg="1.1" :scale="2.9" />
<InkImage src="/assets/eric-gang.jpg" pattern="hatch" inks={['green','orange']} body={0.62} misreg={1.1} scale={2.9} />
Smudge
Every press has a devil. Smudge is ours — an imp of spilled ink, four plates deep, with paper-knockout eyes and a smirk scraped clean off the plate. He haunts our empty states and 404s. Re-ink him below.
<HInkMark name="smudge" :inks="['red', 'black']" :scale="2.8" />
<InkMark name="smudge" inks={["red", "black"]} scale={2.8} />
Utils
Palette
The seven HalftoneColor values, rendered by the engine itself.