Notebook

No dependencies
Code format
React + Tailwind
import { useId, useLayoutEffect, useRef, useState } from 'react';

const LINE = 22;
const LINES = 5; // ruled lines per page
const PAGE = LINE * LINES;

const css = `
  .field-notebook { --paper: light-dark(#fbfcfd, #e5e6e9); --ink: light-dark(#373a40, #121417); --rule: color-mix(in oklab, var(--paper) 74%, light-dark(#6d7179, #7d8087)); display: grid; justify-items: center; }
  .field-notebook__pad { position: relative; display: block; width: 236px; height: 178px; perspective: 700px; }
  .field-notebook__page, .field-notebook__sheet { position: absolute; inset: 8px 0 0; border-radius: 3px 3px 6px 6px; background: radial-gradient(circle at 50% 10px, light-dark(#fbfcfd, #121417) 3px, transparent 3.5px) 4px 0 / 20px 20px repeat-x, linear-gradient(90deg, transparent 27px, color-mix(in oklab, light-dark(#6d7179, #7d8087) 55%, transparent) 27px 28.2px, transparent 28.2px), repeating-linear-gradient(transparent 0 ${LINE - 3}px, var(--rule) ${LINE - 3}px ${LINE - 2}px, transparent ${LINE - 2}px ${LINE}px) 0 26px / 100% ${PAGE}px no-repeat, var(--paper); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.2)), 0 10px 18px -8px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
  .field-notebook__page { box-shadow: 0 1px 0 color-mix(in oklab, var(--paper) 80%, light-dark(#6d7179, #7d8087)), 0 2px 0 var(--paper), 0 3px 0 color-mix(in oklab, var(--paper) 80%, light-dark(#6d7179, #7d8087)), 0 10px 18px -8px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
  .field-notebook__pad:has(.field-notebook__input:focus-visible) .field-notebook__page { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
  /* The spiral: one wire loop per hole, over the top edge. */
  .field-notebook__rings { position: absolute; left: 10px; right: 10px; top: 0; z-index: 3; height: 18px; background: radial-gradient(4px 8px at 50% 50%, transparent 0 2.4px, light-dark(#5f626a, #a7abb2) 2.6px 4px, transparent 4.2px) 0 0 / 20px 18px repeat-x; pointer-events: none; filter: drop-shadow(0 1px 1px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); }
  .field-notebook__window { position: absolute; left: 34px; right: 12px; top: 34px; z-index: 1; height: ${PAGE}px; overflow: clip; clip-path: inset(4px 0 0 0); }
  .field-notebook__input, .field-notebook__text, .field-notebook__measure { position: absolute; left: 0; right: 0; top: 0; box-sizing: border-box; width: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: var(--ink); font: 15px/${LINE}px Noteworthy, 'Segoe Print', 'Bradley Hand', 'Comic Sans MS', cursive; overflow: hidden; overflow-wrap: break-word; white-space: pre-wrap; }
  .field-notebook__input { resize: none; outline: none; caret-color: var(--ink); }
  .field-notebook__measure { visibility: hidden; pointer-events: none; }
  .field-notebook__text { height: ${PAGE}px; }
  .field-notebook__input::placeholder { color: color-mix(in oklab, var(--ink) 40%, var(--paper)); }
  .field-notebook__num { position: absolute; right: 12px; bottom: 9px; color: color-mix(in oklab, var(--ink) 55%, var(--paper)); font: 12px Noteworthy, 'Segoe Print', 'Bradley Hand', cursive; }
  /* The turning leaf: a copy of the page that was full, folding over the rings. */
  .field-notebook__sheet { z-index: 2; transform-origin: 50% 0; transform-style: preserve-3d; pointer-events: none; }
  .field-notebook__sheet::after { content: ''; position: absolute; inset: 0; border-radius: inherit; background: repeating-linear-gradient(transparent 0 ${LINE - 3}px, var(--rule) ${LINE - 3}px ${LINE - 2}px, transparent ${LINE - 2}px ${LINE}px) 0 26px / 100% ${PAGE}px no-repeat, color-mix(in oklab, var(--paper) 94%, light-dark(#6d7179, #7d8087)); transform: rotateX(180deg); backface-visibility: hidden; }
  .field-notebook__sheet .field-notebook__text { left: 34px; right: 12px; top: 26px; width: auto; backface-visibility: hidden; }
  .field-notebook__sheet--over { animation: field-notebook-over 0.8s cubic-bezier(0.45, 0, 0.3, 1) forwards; }
  .field-notebook__sheet--back { animation: field-notebook-back 0.7s cubic-bezier(0.45, 0, 0.3, 1) forwards; }
  @keyframes field-notebook-over { 0% { transform: rotateX(0deg); } 100% { transform: rotateX(-176deg); opacity: 0.4; } }
  @keyframes field-notebook-back { 0% { transform: rotateX(-176deg); opacity: 0.4; } 100% { transform: rotateX(0deg); } }
  @media (prefers-reduced-motion: reduce) {
    .field-notebook__sheet { display: none; }
  }
`;

export function Preview() {
  const id = useId();
  const measure = useRef<HTMLSpanElement>(null);
  const [text, setText] = useState('Pick up the film, call Sam back about Saturday, and water the fern.');
  const [caret, setCaret] = useState(text.length);
  const [page, setPage] = useState(0);
  const [pages, setPages] = useState(1);
  const [turn, setTurn] = useState<{ dir: 'over' | 'back'; page: number; text: string; n: number } | null>(null);

  // The page follows the caret: a hidden copy of the text up to it says which ruled line it is on.
  useLayoutEffect(() => {
    const m = measure.current!;
    const lines = (upTo: string) => {
      m.textContent = `${upTo}\u200b`;
      return Math.max(1, Math.round(m.offsetHeight / LINE));
    };
    const next = Math.floor((lines(text.slice(0, caret)) - 1) / LINES);
    setPages(Math.floor((lines(text) - 1) / LINES) + 1);
    if (next !== page) {
      setTurn((t) => ({ dir: next > page ? 'over' : 'back', page: next > page ? page : next, text, n: (t?.n ?? 0) + 1 }));
      setPage(next);
    }
  }, [text, caret, page]);

  const track = (e: { currentTarget: HTMLTextAreaElement }) => setCaret(e.currentTarget.selectionStart);
  return (
    <>
      <style href="kit-inputs-notebook" precedence="kit">
        {css}
      </style>
      <div className="field-notebook">
        <label className="field-notebook__pad" htmlFor={id}>
          <span className="field-notebook__page" aria-hidden="true" />
          <span className="field-notebook__window">
            <span ref={measure} className="field-notebook__measure" aria-hidden="true" />
            <textarea
              id={id}
              className="field-notebook__input"
              aria-label="Note"
              placeholder="Write something…"
              value={text}
              style={{ height: Math.max(pages, page + 1) * PAGE, transform: `translateY(${-page * PAGE}px)` }}
              onChange={(e) => {
                setText(e.target.value);
                track(e);
              }}
              onSelect={track}
            />
          </span>
          {turn && (
            <span key={turn.n} className={`field-notebook__sheet field-notebook__sheet--${turn.dir}`} aria-hidden="true" onAnimationEnd={() => setTurn(null)}>
              <span className="field-notebook__text" style={{ overflow: 'hidden' }}>
                <span style={{ display: 'block', transform: `translateY(${-turn.page * PAGE}px)` }}>{turn.text}</span>
              </span>
            </span>
          )}
          <span className="field-notebook__num" aria-hidden="true">
            p. {page + 1}
          </span>
          <span className="field-notebook__rings" aria-hidden="true" />
        </label>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-notebook
Source on GitHub

About this component

A note on a spiral notebook in handwriting on the ruled lines; when the page is full it flips over the rings and you carry on on a fresh one, and moving the caret back flips it back.

Dependencies
None
Difficulty
Advanced
Triggers
state change, focus, 3d
Techniques
3d transform, keyframes, state machine, gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native textarea named "Note" with a placeholder; focus outlines the page. The page number and the turning sheet are aria-hidden. Under prefers-reduced-motion pages change without the turn.

More inputs

See all

7 of 10 digits

Pager
Plate
Polygraph
Punch Card