On this page

Writing

Building a notepad component in React

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: . A barely-there layer of noise over the top fixes it, generated right in the markup with an SVG , no image to download, and it doubles as paper texture:

// 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 ::before . The next step tilts the card with a transform, and a transform creates a new . Inside one, a -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 drawn switch. An watches the list and sets it the first time the card enters the viewport. If the reader prefers reduced motion, skip the animation and show the checks already drawn.

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