Nº 134 of 520
Paper grain and ink blots
Generative paper grain and two multiply ink blots behind a three-line poem, with a Grain slider that changes overlay opacity and turbulence frequency.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Paper grain and ink blots" (piece background-paper-grain) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 134 · "Paper grain and ink blots" · www.designlounge.live -->
# Paper grain and ink blots
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
A single plate: ivory paper, two translucent ink blots (sienna `#6b2e1f` on the left, Prussian `#1a3352` on the right) with an SVG displacement filter so their edges look pressed, not CSS-rounded, and a full-viewport fractal-noise grain layered on top with `mix-blend-mode: multiply`. In the centre, a three-line poem in italic 42px Literata. A Grain range input at the bottom changes `--grain` (overlay opacity) and the turbulence `baseFrequency`. The slider is the interaction; the composition is the piece. No nav, no CTA.
## Reference behaviour
1. Initial state: paper `#f3ebd8`. Header row "Plate 07" — hairline — "Two inks on laid stock", 11px uppercase Public Sans `--ink-3`. Poem centred. Slider at **45**.
2. Left blot: 540×420px, `left: 8%; top: 18%`, sienna, organic `border-radius: 58% 42% 48% 52% / 46% 54% 46% 54%`, rotate −12deg, `filter: url(#spread)`, `mix-blend-mode: multiply`, opacity 0.82.
3. Right blot: 480×380px, `right: 6%; bottom: 10%`, Prussian, complementary radii, rotate 8deg, same filter and blend.
4. `#spread` filter: `feTurbulence` fractalNoise baseFrequency 0.018, 3 octaves, seed 4, then `feDisplacementMap` scale 42 (R→X, G→Y). This chews the ellipse into a blot.
5. Grain layer: full-size SVG `<rect>` with filter `#grainF` — fractalNoise baseFrequency **0.8**, 4 octaves, seed 2, saturate 0, alpha table `0 / 0.55`. The SVG itself has `opacity: var(--grain)` (initial 0.45) and `mix-blend-mode: multiply`.
6. Slider `min=0 max=100 value=45`, `aria-valuenow` kept in sync. On `input`: set `--grain` to `value/100`, write the numeric label, set turbulence `baseFrequency` to `(0.45 + value/100 * 0.7).toFixed(3)` so 0 → 0.45 (fine, quiet) and 100 → 1.15 (coarse, heavy).
7. Poem does not move. Blots do not animate. Only the grain overlay changes with the slider.
8. Reduced motion: no extra animation to disable (the piece is static besides the slider). Keep the slider working.
## Structure
```
1280 × 800
┌────────────────────────────────────────────────────────────┐
│ PLATE 07 ────────────── TWO INKS ON LAID STOCK │ 11px
│ · │
│ (sienna blot, back left) │
│ The grain remembers │ italic 42
│ every press of the plate. │
│ Two inks, one afternoon. │
│ — FROM THE BINDERY NOTES, 2026 │
│ (prussian blot, right) │
│ Grain ────●────────────── 45 │ range
└────────────────────────────────────────────────────────────┘
```
- Two SVG `<filter>`s in a 0×0 SVG: `#spread` (displacement) and `#grainF` (noise).
- `.bg` absolute inset 0, `pointer-events: none`: `.blot.b1`, `.blot.b2`, `svg.grain`.
… 130 more lines. Copy or download for the full brief. Loading the source…Brief
170 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
A single plate: ivory paper, two translucent ink blots (sienna #6b2e1f on the left, Prussian #1a3352 on the right) with an SVG displacement filter so their edges look pressed, not CSS-rounded, and a full-viewport fractal-noise grain layered on top with mix-blend-mode: multiply. In the centre, a three-line poem in italic 42px Literata. A Grain range input at the bottom changes --grain (overlay opacity) and the turbulence baseFrequency. The slider is the interaction; the composition is the piece. No nav, no CTA.
Reference behaviour
- Initial state: paper
#f3ebd8. Header row “Plate 07” — hairline — “Two inks on laid stock”, 11px uppercase Public Sans--ink-3. Poem centred. Slider at 45. - Left blot: 540×420px,
left: 8%; top: 18%, sienna, organicborder-radius: 58% 42% 48% 52% / 46% 54% 46% 54%, rotate −12deg,filter: url(#spread),mix-blend-mode: multiply, opacity 0.82. - Right blot: 480×380px,
right: 6%; bottom: 10%, Prussian, complementary radii, rotate 8deg, same filter and blend. #spreadfilter:feTurbulencefractalNoise baseFrequency 0.018, 3 octaves, seed 4, thenfeDisplacementMapscale 42 (R→X, G→Y). This chews the ellipse into a blot.- Grain layer: full-size SVG
<rect>with filter#grainF— fractalNoise baseFrequency 0.8, 4 octaves, seed 2, saturate 0, alpha table0 / 0.55. The SVG itself hasopacity: var(--grain)(initial 0.45) andmix-blend-mode: multiply. - Slider
min=0 max=100 value=45,aria-valuenowkept in sync. Oninput: set--graintovalue/100, write the numeric label, set turbulencebaseFrequencyto(0.45 + value/100 * 0.7).toFixed(3)so 0 → 0.45 (fine, quiet) and 100 → 1.15 (coarse, heavy). - Poem does not move. Blots do not animate. Only the grain overlay changes with the slider.
- Reduced motion: no extra animation to disable (the piece is static besides the slider). Keep the slider working.
Structure
1280 × 800
┌────────────────────────────────────────────────────────────┐
│ PLATE 07 ────────────── TWO INKS ON LAID STOCK │ 11px
│ · │
│ (sienna blot, back left) │
│ The grain remembers │ italic 42
│ every press of the plate. │
│ Two inks, one afternoon. │
│ — FROM THE BINDERY NOTES, 2026 │
│ (prussian blot, right) │
│ Grain ────●────────────── 45 │ range
└────────────────────────────────────────────────────────────┘
- Two SVG
<filter>s in a 0×0 SVG:#spread(displacement) and#grainF(noise). .bgabsolute inset 0,pointer-events: none:.blot.b1,.blot.b2,svg.grain..pagerelative z-index 1, column flex, padding 40/64/36:.top,<main>(flex 1, centres the<blockquote>),.ctl.<blockquote>: three<p>lines +<footer>..ctl:<label for="g">Grain</label>,<input id="g" type="range">,<span id="gv">45</span>.
Poem lines, exact:
- The grain remembers
- every press of the plate.
- Two inks, one afternoon.
Attribution: — from the bindery notes, 2026
Tokens
:root {
--paper: #f3ebd8; /* page */
--ink: #2b1f18; /* poem */
--ink-2: #5e4e42; /* attribution */
--ink-3: #8a7666; /* plate caption, label */
--sienna: #6b2e1f; /* left blot */
--prussian: #1a3352; /* right blot */
--line: rgba(43, 31, 24, .16);
--serif: "Literata", Georgia, serif;
--sans: "Public Sans", system-ui, sans-serif;
--grain: .45; /* overlay opacity, 0–1 */
--t-micro: 160ms;
--ease: cubic-bezier(.2, .7, .2, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Poem line | Literata | 42px | italic 400 | 1.25 | −0.02em | sentence |
| Attribution | Public Sans | 12px | 500 | 1 | +0.14em | UPPERCASE |
| Plate label | Public Sans | 11px | 500 | 1 | +0.16em | UPPERCASE |
| Slider label | Public Sans | 11px | 500 | 1 | +0.12em | UPPERCASE |
| Slider value | Public Sans | 12px | 500 | 1 | 0 | tabular |
Poem max-width: 22ch, text-align: center, font-optical-sizing: auto. Line margin-bottom 0.35em.
Motion
There is no looping motion. The slider is the only changing property:
| Element | Trigger | Property | From → To |
|---|---|---|---|
| Grain SVG | input | opacity --grain | 0 → 1 (slider 0–100) |
#grainF turb. | input | baseFrequency | 0.45 → 1.15 |
Do not animate the blots. Reduced motion: no change required.
States
- Slider thumb: 14px circle, paper fill, 2px ink stroke. Track is 2px ink, no radius.
- Slider focus-visible: 2px Prussian outline, 3px offset, on the input.
- Grain at 0: blots and poem remain; the paper reads as smooth stock.
- Grain at 100: heavy multiply noise, frequency 1.15. Poem must stay readable (ink on paper still exceeds 8:1; grain is overlay, not type).
Accessibility
- Label is a real
<label for="g">.aria-valuemin/max/nowon the range. Visible numeric value in#gv(notsr-only). - Background blots and grain are
aria-hidden. The poem is a<blockquote>with a<footer>for the attribution. - Contrast: poem ink
#2b1f18on#f3ebd8is about 11:1. Do not lighten the poem to sit “on” the blot; the multiply blots sit behind the type and the type is a solid ink colour. - Keyboard: Tab to the slider, arrow keys change the value (native).
Responsive rules
- ≥ 1280: blots 540/480 as specified, poem 42px.
- 1024–1279: scale blots with
width: 42vwandmax-width: 540px. Poem 36px. - 768–1023: poem 32px, 28ch measure. Slider full width minus 48px padding. Blots 50vw and may overlap the poem more — that is acceptable.
- < 640: poem 26px/1.3. Stack the plate labels without the hairline. Slider still full width. Keep both blots; do not hide them.
Acceptance checklist
- Two multiply blots: sienna left (
#6b2e1f), Prussian right (#1a3352), displaced by SVG turbulence, not plain ellipses. - Full-viewport grain overlay, multiply, opacity tied to
--grain. - Slider default 45; at 0 the grain disappears; at 100 opacity is 1 and baseFrequency is 1.15.
- Poem is exactly the three lines above, 42px italic Literata, centred, 22ch.
- No nav, no button, no looping animation.
- Plate caption “Plate 07” / “Two inks on laid stock” in 11px uppercase Public Sans.
- Focus ring on the slider is 2px
#1a3352. - Only Literata and Public Sans load.
- Palette is ivory / sienna / Prussian, not purple-blue blobs and not the site’s Fraunces paper.
Implementation notes
Displacement for the blot edge — a CSS border-radius blob is too smooth; chew it:
<filter id="spread" x="-20%" y="-20%" width="140%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency=".018" numOctaves="3" seed="4" result="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="42"
xChannelSelector="R" yChannelSelector="G"/>
</filter>
Give the filter extra region (x="-20%") or the displacement clips.
Grain as a filtered rect, not a canvas loop:
<filter id="grainF">
<feTurbulence type="fractalNoise" baseFrequency=".8" numOctaves="4" seed="2"/>
<feColorMatrix type="saturate" values="0"/>
<feComponentTransfer><feFuncA type="table" values="0 .55"/></feComponentTransfer>
</filter>
Slider writes two things, opacity and frequency:
inp.addEventListener('input', () => {
const v = +inp.value;
root.style.setProperty('--grain', String(v / 100));
gv.textContent = v;
turb.setAttribute('baseFrequency', (0.45 + v / 100 * 0.7).toFixed(3));
});
Keep feColorMatrix saturate 0 so the grain stays ink-neutral and does not tint the Prussian blot green. Do not regenerate the filter node on every input — only mutate baseFrequency and --grain.
Common mistakes: mix-blend-mode: overlay (washes the type). Animating blot position (the plate should sit still). Using canvas getImageData at 1280×800 on every input (too heavy; SVG turbulence is the grain).
Details
- Palette
- Type
- Literata · Public Sans
- Motion
- Subtle motion
- Build
- One session
- Tags
Studied
Susan AcharyaDraw one scene and let it move a little. The words stay calm on top.
All sources