7 of 10 digits
Notebook
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldNotebook(el) on the mounted element instead.
<div class="field-notebook">
<label class="field-notebook__pad" for="field-notebook">
<span class="field-notebook__page" aria-hidden="true"></span>
<span class="field-notebook__window">
<span class="field-notebook__measure" aria-hidden="true"></span>
<textarea id="field-notebook" class="field-notebook__input" aria-label="Note" placeholder="Write something…" style="height:110px;transform:translateY(0px)">Pick up the film, call Sam back about Saturday, and water the fern.</textarea>
</span>
<span class="field-notebook__num" aria-hidden="true">p. 1</span>
<span class="field-notebook__rings" aria-hidden="true"></span>
</label>
</div>.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 19px, var(--rule) 19px 20px, transparent 20px 22px) 0 26px / 100% 110px 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: 110px; 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/22px 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: 110px; }
.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 19px, var(--rule) 19px 20px, transparent 20px 22px) 0 26px / 100% 110px 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; }
}function initFieldNotebook(root) {
var LINE = 22;
var LINES = 5; // ruled lines per page
var PAGE = LINE * LINES;
var pad = root.querySelector('.field-notebook__pad');
var measure = root.querySelector('.field-notebook__measure');
var input = root.querySelector('.field-notebook__input');
var num = root.querySelector('.field-notebook__num');
var page = 0;
var sheet = null;
// A hidden copy of the text up to a point says how many ruled lines it fills.
function lines(upTo) {
measure.textContent = upTo + '';
return Math.max(1, Math.round(measure.offsetHeight / LINE));
}
// The turning leaf: a copy of the page that was full, folding over the rings (or back).
function turn(dir, at, text) {
if (sheet) sheet.remove();
sheet = document.createElement('span');
sheet.className = 'field-notebook__sheet field-notebook__sheet--' + dir;
sheet.setAttribute('aria-hidden', 'true');
sheet.innerHTML = '<span class="field-notebook__text" style="overflow:hidden"><span style="display:block;transform:translateY(' + -at * PAGE + 'px)"></span></span>';
sheet.querySelector('span span').textContent = text;
sheet.addEventListener('animationend', function (e) {
e.currentTarget.remove();
});
pad.insertBefore(sheet, num);
}
// The page follows the caret.
function update() {
var text = input.value;
var caret = input.selectionStart == null ? text.length : input.selectionStart;
var next = Math.floor((lines(text.slice(0, caret)) - 1) / LINES);
var pages = Math.floor((lines(text) - 1) / LINES) + 1;
if (next !== page) {
turn(next > page ? 'over' : 'back', next > page ? page : next, text);
page = next;
}
input.style.height = Math.max(pages, page + 1) * PAGE + 'px';
input.style.transform = 'translateY(' + -page * PAGE + 'px)';
num.textContent = 'p. ' + (page + 1);
}
function onSelection() {
if (document.activeElement === input) update();
}
update();
input.addEventListener('input', update);
document.addEventListener('selectionchange', onSelection);
return function () {
input.removeEventListener('input', update);
document.removeEventListener('selectionchange', onSelection);
};
}
document.querySelectorAll('.field-notebook').forEach(initFieldNotebook);Goal: Create a "Notebook" input.
Visual behavior: 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.
Technique:
- A real textarea sits on the page in the handwriting font. A hidden copy of the text up to the caret measures which ruled line the caret is on, which says which page to show.
- The textarea sits in a clipped window one page tall and is moved up a page at a time, so the page always shows the lines the caret is on.
- Turning the page lays a copy of the full page over it that folds over the rings with rotateX, its back drawn on a pseudo-element; going back plays the fold in reverse.
- The wire rings, punched holes, margin line and rules are all CSS gradients, and the page number follows the page.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Notebook" input.
Visual behavior: 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.
Technique:
- A real textarea sits on the page in the handwriting font. A hidden copy of the text up to the caret measures which ruled line the caret is on, which says which page to show.
- The textarea sits in a clipped window one page tall and is moved up a page at a time, so the page always shows the lines the caret is on.
- Turning the page lays a copy of the full page over it that folds over the rings with rotateX, its back drawn on a pseudo-element; going back plays the fold in reverse.
- The wire rings, punched holes, margin line and rules are all CSS gradients, and the page number follows the page.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.