A ruled-paper notepad has blue lines, a red margin rule, and a checklist. It’s just a couple of CSS gradients, a stacked shadow, and a few lines of JavaScript. No background image needed.
We’ll build it as a React component styled with Tailwind. Here’s the finished piece:
- Ruled lines from one gradient
- Light-from-above shadow
- Debanded with grain
- Draws itself in
Setting up the project
We’ll build this in a plain Vite + React app with Tailwind, so there’s nothing project-specific to get in the way. If you already have somewhere to drop a component, skip ahead. Otherwise, spin up a fresh one from an empty folder:
npm create vite@latest notepad -- --template react-ts
cd notepad
npm install
That gives us a React + TypeScript app. Now add Tailwind. As of v4 it’s a single Vite plugin: no config file, no PostCSS dance.
npm install tailwindcss @tailwindcss/vite
Register the plugin in vite.config.ts:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});
And replace the contents of src/index.css with the one line that pulls Tailwind in:
/* src/index.css */
@import "tailwindcss";
The Vite starter already imports that stylesheet from src/main.tsx, so there’s nothing else
to wire up. Start the dev server and leave it running. It hot-reloads as we go:
npm run dev
We’ll write our component in src/Notepad.tsx and render it from src/App.tsx:
// src/App.tsx
import { Notepad } from './Notepad';
export default function App() {
return <Notepad />;
}
Open the printed localhost URL and you’ve got a blank page waiting for the component.
Starting with the markup
Before any of the paper stuff, let’s get the content on the page. It’s an unordered list, one
<li> per item, and each row has a checkmark and some text. The items are just an array of
strings, so the component stays reusable:
// src/Notepad.tsx
const items = [
'Ruled lines from one gradient',
'Light-from-above shadow',
'Debanded with grain',
'Draws itself in',
];
export function Notepad() {
return (
<ul className="m-0 list-none rounded-[18px] border border-[#e7e2d6] bg-white px-5 py-3.5 text-base font-medium text-[#2b2620]">
{items.map((item) => (
<li key={item} className="flex items-center gap-2.5 py-1.5">
<svg viewBox="0 0 24 24" fill="none" className="h-[18px] w-[18px] shrink-0">
<path d="M5 12.5 11 18 19.5 6.5" stroke="currentColor" strokeWidth={2.3} />
</svg>
<span>{item}</span>
</li>
))}
</ul>
);
}
A bordered box with a list in it:
- Ruled lines from one gradient
- Light-from-above shadow
- Debanded with grain
- Draws itself in
Drawing the lines
You don’t need an image for the ruling. A repeating-linear-gradient draws an infinite stack
of evenly-spaced lines for you, and a regular linear-gradient gives us the single vertical
margin rule. Tailwind doesn’t have a utility for stacking gradients like this, so we drop down
to an inline style:
// src/Notepad.tsx
const LINE = '2.15rem'; // the spacing between rules
const MX = '2.2rem'; // how far the margin rule sits from the left
style={{
backgroundImage: [
// the red margin line down the left
`linear-gradient(to right, transparent ${MX}, rgb(214 99 88 / .5) ${MX},
rgb(214 99 88 / .5) calc(${MX} + 1.5px), transparent calc(${MX} + 1.5px))`,
// every horizontal blue rule, from one repeating gradient
`repeating-linear-gradient(to bottom, transparent 0, transparent calc(${LINE} - 1px),
rgb(96 128 188 / .32) calc(${LINE} - 1px), rgb(96 128 188 / .32) ${LINE})`,
].join(', '),
}}
The line spacing lives in a constant, LINE, because the next bit depends on it.
Stop here and the text floats in the gaps between the lines instead of sitting on them. The
fix: set each row’s line-height to the exact rule spacing. Every row is then one rule tall,
so the text lands on a line, and a label long enough to wrap drops its second line to the
next rule and stays aligned. No magic numbers.
// src/Notepad.tsx
<li
className="my-0 flex min-h-[2.15rem] items-center gap-2.5 pr-5 leading-[2.15rem]"
style={{
paddingLeft: `calc(${MX} + .85rem)`, // nudge past the margin rule
textShadow: '0 1px 0 rgb(255 255 255 / .4)', // hairline highlight under the glyphs
}}
>
The rules are blue, the margin red, the checks ink-grey, and now the text has a hairline light highlight just below each glyph, like paper catching light at the edge of a groove. Four small touches, and that’s why this reads as paper.
- Ruled lines from one gradient
- Light-from-above shadow
- Debanded with grain
- Draws itself in
A checkmark that looks drawn
A checkbox character reads too mechanical on handwriting-style paper, so we’ll draw the tick
ourselves with a single SVG path, one curved stroke with rounded ends, so it reads like it
came from a pen. A tiny -rotate-[4deg] stops it from looking laser-straight:
// src/Notepad.tsx
<svg viewBox="0 0 24 24" fill="none" className="h-[18px] w-[18px] shrink-0 -rotate-[4deg] text-[#837a6c]">
<path
d="M4 13c1.7 1.9 3.1 3.8 4.6 5.8.4.5 1 .4 1.3-.1C11.8 14 15 9.3 20.2 4.8"
stroke="currentColor" strokeWidth={2.7} strokeLinecap="round" strokeLinejoin="round"
/>
</svg>
- Ruled lines from one gradient
- Light-from-above shadow
- Debanded with grain
- Draws itself in
A shadow that behaves like real light
The sheet should sit slightly off the page. Real light comes from above, so the shadow should only fall below the object. Every layer gets a positive vertical offset and a negative spread to keep it there.
That takes several stacked layers, which Tailwind can’t fit into one class, so we define it once and apply it inline:
// src/Notepad.tsx
const SHADOW =
'inset 0 1px 1px rgb(255 255 255 / .9), 0 1px 2px rgb(70 48 12 / .1), ' +
'0 14px 24px -6px rgb(243 212 155 / .6), 0 30px 50px -14px rgb(243 212 155 / .48)';
<ul style={{ boxShadow: SHADOW, /* ...and the gradients from before */ }}>
The shadow color is a faded amber. Paper casts a warm shadow, and that warmth is what stops it reading as a generic UI card.
- Ruled lines from one gradient
- Light-from-above shadow
- Debanded with grain
- Draws itself in
Grain, and a second sheet behind
Two finishing touches. First, a big soft gradient on a wide monitor shows faint stripes: What is banding? Visible steps in a gradient instead of a smooth fade, caused by too few color values to cover the distance. What is feTurbulence? An SVG filter primitive that generates procedural noise: no image file, just math, rendered straight in the markup.
// src/Notepad.tsx
<span aria-hidden className="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-overlay"
style={{ opacity: 0.04, backgroundImage: "url(\"data:image/svg+xml,...feTurbulence...\")" }} />
Second, a single sheet becomes a pad once another sheet shows behind it. That sheet has to
be a real sibling element, not a What is a pseudo-element? A generated box CSS lets you attach to an element, like What is a stacking context? A self-contained layer for ::before ::before or ::after, without an extra tag in the markup.transform, and a transform creates a
new z-index. Properties like transform and opacity create one, and z-index values inside it only compare against siblings in that same layer, not the whole page.-z-10 pseudo-element stops painting behind its parent
and starts painting on top of its background instead. A separate sibling avoids it:
// src/Notepad.tsx
<div className="relative isolate">
<div aria-hidden className="absolute inset-0 -z-10 rounded-[26px] bg-[#f2e7cf]"
style={{ transform: 'translate(5px, 9px)' }} />
<ul className="relative ...">{/* the paper */}</ul>
</div>
- Ruled lines from one gradient
- Light-from-above shadow
- Debanded with grain
- Draws itself in
The tilt, and a hover that feels right
Set the card at a slight angle (-rotate-2) so it looks placed rather than pasted. Don’t
animate the rotation on hover, though: straightening the card as you mouse over it reads as a
wobble. Leave the angle fixed and only translate it straight up: that reads as picking it up
off the desk.
// src/Notepad.tsx
<ul className="-rotate-2 transition duration-200 hover:-translate-y-[5px] ...">
- Ruled lines from one gradient
- Light-from-above shadow
- Debanded with grain
- Draws itself in
The flourish: drawing the checks in
Last step: the checkmarks draw themselves in as the card scrolls into view.
The animation itself is pure CSS and leans on a classic SVG trick. Set stroke-dasharray to
the length of the path and the stroke vanishes (it’s now one dash exactly as long as the
line, shifted out of view). Transition stroke-dashoffset back to 0 and the stroke sweeps
back on. Give each row a slightly bigger transition-delay and they tick down the list in
sequence:
// src/Notepad.tsx (on each tick's <path>):
style={{
strokeDasharray: 40,
strokeDashoffset: drawn ? 0 : 40,
transition: 'stroke-dashoffset 480ms cubic-bezier(.23, 1, .32, 1)',
transitionDelay: `${i * 70}ms`,
}}
JavaScript only decides when to flip the What is IntersectionObserver? A browser API that reports when an element enters or leaves the viewport, without a scroll listener recalculating positions on every frame.drawn switch. An
// src/Notepad.tsx
const ref = useRef<HTMLUListElement>(null);
const [drawn, setDrawn] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || !matchMedia('(prefers-reduced-motion: no-preference)').matches) {
setDrawn(true);
return;
}
const io = new IntersectionObserver(([entry], obs) => {
if (entry.isIntersecting) { setDrawn(true); obs.disconnect(); }
}, { threshold: 0.5 });
io.observe(el);
return () => io.disconnect();
}, []);
Scroll it back into view and the checks redraw:
- Ruled lines from one gradient
- Light-from-above shadow
- Debanded with grain
- Draws itself in
Change LINE and the whole thing re-spaces. Swap the two rule colors and it’s a different
notebook.