Dotted Notepad Surface
A panel textured with a dot grid drawn by a single tiled radial gradient, masked so the pattern dissolves toward the edges instead of ending on a rectangle. An optional turned-down corner is one hard-stopped linear gradient. Nothing is an image, nothing animates, and the whole surface costs one background layer.
Dotted Notepad Surface
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/dotted-notepad-surface.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Panel content. Rendered above the dot layer. |
| pitch | number | = 26 | Grid pitch in pixels. Becomes background-size. |
| dotSize | number | = 1.1 | Dot radius in pixels. The transparent stop lands 0.2px further out. |
| fade | string | = '10%' | How far the edge mask reaches in from each side. |
| scrollable | boolean | = false | Makes the dotted layer the scroll container, which is what gives attachment meaning. |
| attachment | 'scroll' | 'local' | 'fixed' | = 'scroll' | Tile origin. scroll holds the grid still under scrolling content; local scrolls the grid with it. |
| fold | boolean | = false | Draws the turned-down top-right corner. |
| radius | string | = '20px' | Panel corner radius. The fold inherits it on its own corner. |
| className | string | — | Merged onto the panel. Use for sizing and border. |
| contentClassName | string | — | Merged onto the content wrapper. Put padding here so text clears the mask. |
How it works
The technique in order, not the code in order.
One
radial-gradient(circle, dot 1.1px, transparent 1.3px)is tiled bybackground-size: 26px 26px, sobackground-sizealone controls the pitch of the grid and the gradient controls the dot. Changing pitch never requires touching the gradient.The 0.2px between the coloured stop and the transparent stop is the antialiasing ramp. With both stops at the same offset the dot edge quantises to the device pixel grid and the dots render as squares at fractional zoom; wider than about 0.4px and they visibly blur.
background-position: centerrather than0 0keeps the lattice symmetric about the panel as it resizes. Pinned to the top-left, a resize crops the pattern on one side only and the grid appears to slide.mask-imagetakes two axis gradients — one vertical, one horizontal — each opaque in the middle and transparent for--kx-pad-fadeat both ends, combined withmask-composite: intersect. Intersecting two linear masks fades all four edges while keeping the interior perfectly flat; a single radial mask would instead darken the corners more than the edges and make a rectangular panel read as a lens.background-attachmentdecides what the tile origin is anchored to, and only matters once the dotted element is itself a scroll container.scrollanchors to the element border box so the grid holds still while content slides over it;localanchors to the scrolled content so the grid travels with the text and the panel reads as one continuous sheet.The fold is a single absolutely-positioned square carrying
linear-gradient(225deg, void 0 43%, crease 43% 48%, under 48% 100%). Hard stops (a stop pair at the same offset) mean no interpolation, so the boundary is a crease and not a shadow. The 225deg gradient line points down-left, which puts the void band at the top-right corner where the paper is turned back.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| pitch | = 26px | Tile size, therefore dot spacing. Below ~18px the grid starts to read as a flat tone rather than a texture and interferes with body text; above ~34px the dots read as individual objects on the page and compete with content. |
| dotSize | = 1.1px | Dot radius. At exactly 1px the dot lands on a whole device pixel and hardens; the fractional value is what keeps it soft on 1x displays. Above ~1.6px the surface reads as polka dots rather than paper grain. |
| antialias gap | = 0.2px | Distance from the colour stop to the transparent stop. This is the only softening the dot gets. At 0 the edge quantises and the dots go square at fractional zoom levels; at 0.5px and above the dot loses its centre and the grid looks smudged. |
| fade | = 10% | How far the edge mask reaches in. Below ~6% the dissolve is too abrupt to read as intentional and looks like a clipping bug; above ~18% the usable flat area of a small panel disappears and content near the edge visibly ghosts. |
| fold stop positions | = 43% / 48% | The crease is a 5% sliver. Thinner than ~3% and it drops below a pixel on a small fold and disappears; wider than ~8% and it stops reading as a lit edge and becomes a grey stripe. The 43% start places the crease off-centre so the flap looks lifted rather than symmetrically folded. |
| shadow | = 0 34px 70px -34px | Negative spread equal to the Y offset. It shrinks the shadow box by exactly the amount the offset moved it, so the blur only escapes below the panel. Without the negative spread a 70px blur haloes all four sides and the sheet looks lit from nowhere. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
Nothing in this component moves in either state — there is no animation, transition or timer to disable. The surface renders identically with prefers-reduced-motion: reduce set, including the fold and the edge fade.
Nothing to clean up. No listeners, no timers, no animation frames. The hoisted <style> is reference-counted by React and removed when the last instance unmounts.
The dot grid is crisp at 100% and at 150% browser zoom — individual dots stay round, not square. All four edges dissolve rather than ending on a line, and the interior is perfectly even. With scrollable and attachment="local", scrolling the panel moves the dots with the text; with attachment="scroll" the dots stay put while the text slides over them. With fold, the corner reads as turned paper with a single sharp crease and no blur.
Pitfalls
- Putting the mask on the panel and expecting only the dots to fade. A mask applies to everything the element paints, children included, so text within
--kx-pad-fadeof an edge ghosts out. Content padding must exceed the fade inset — that is why the component has a separate content wrapper for padding rather than padding the panel. - Reaching for
background-attachment: fixedto get a parallax grid. The tile origin becomes the viewport, but any ancestor with atransform,filter,backdrop-filterorwill-changebecomes the containing block instead, so the effect silently changes meaning as soon as someone animates a parent. It also repaints the element across its full area on every scroll frame, and iOS Safari has never implemented it usefully. - Setting
background-attachment: localon a panel that does not scroll and expecting a difference.localandscrollare identical until the element itself has overflow — the value is inert, so the "fix" appears to do nothing and gets removed. - Coincident gradient stops on the dot (
radial-gradient(circle, c 1.1px, transparent 1.1px)). It looks correct in a screenshot at 100% zoom and turns into a field of tiny squares on a 1.25x or 1.5x display. - Using the
backgroundshorthand after settingbackground-size. The shorthand resetsbackground-sizetoauto, the tile collapses to the gradient default, and the grid becomes one enormous dot. - Assuming the fold reads as a hole without matching
--kx-pad-voidto what is actually behind the panel. The first band of the fold gradient is a flat colour pretending to be the page; against a different background it reads as a grey triangle stuck on the corner.
Notes
mask-composite: intersectneeds Safari 15.4+, Chrome 120+ and Firefox 53+. Where it is unsupported only the first mask layer applies, so the grid fades top and bottom but not left and right — a degradation, not a break.- The dot layer is a real background rather than a pseudo-element so that
background-attachmenthas a scroll container to refer to. A decorative absolutely-positioned overlay could not express thelocalbehaviour at all. - The CSS ships inside the component as a
<style href precedence>element; React 19 hoists it into<head>and de-duplicates byhref, so any number of panels contribute one rule set.