notes
Tufte-style numbered margin notes (CSS-counter ordered, tap-to-expand below 1280px) and dotted-underline term popovers with viewport-edge flip.
prosesidenotetooltiptypography
Preview
Margin notes number themselves in document orderThis is a margin note. On wide screens it sits in the right gutter; on narrow ones it becomes this tap-to-expand disclosure.and a termA short inline definition that appears on hover or keyboard focus, flipping sides near the viewport edge. reveals its definition without leaving the sentence.
A second noteCounters need no registration — this one is “2” automatically. keeps counting on its own.
Installation
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/notes.jsonManual
Copy each file from the Source section into its target path:
registry/notes/notes.tsx → components/moco/notes.tsx
registry/notes/notes.css → components/moco/notes.cssNeeds moco items: tokens
Usage
usage.tsx
"use client";
import * as React from "react";
import { Note, Term } from "./notes";
export default function NotesDemo() {
return (
<div className="moco-notes-scope" style={{ maxWidth: 640, margin: "0 auto" }}>
<p>
Margin notes number themselves in document order
<Note>
This is a margin note. On wide screens it sits in the right gutter;
on narrow ones it becomes this tap-to-expand disclosure.
</Note>
and a{" "}
<Term def="A short inline definition that appears on hover or keyboard focus, flipping sides near the viewport edge.">
term
</Term>{" "}
reveals its definition without leaving the sentence.
</p>
<p>
A second note
<Note>Counters need no registration — this one is “2” automatically.</Note>{" "}
keeps counting on its own.
</p>
</div>
);
}
Source
components/moco/notes.tsx
"use client";
import * as React from "react";
import "./notes.css";
/**
* Tufte-style margin note. Numbering comes from a CSS counter, so it is always
* document order with no registration bookkeeping. On screens ≥1280px it sits
* in the right gutter; below that it collapses to a tap-to-expand disclosure.
* There is no jump-to-bottom footnote anywhere.
*
* Wrap your prose column in `.moco-notes-scope` — it initializes the counter
* and is the positioned ancestor the margin notes hang off.
*/
export interface NoteProps {
children: React.ReactNode;
}
/* Margin notes are absolutely positioned at their anchor's line, so two notes
anchored close together would overlap in the gutter. After layout, walk the
scope's notes in document order and push each below the previous one.
Idempotent: safe to run from every note on mount, resize, or font load. */
function relayoutScope(scope: Element) {
const notes = scope.querySelectorAll<HTMLElement>(".moco-sidenote-wrap");
let prevBottom = -Infinity;
notes.forEach((n) => {
n.style.removeProperty("--moco-note-shift");
const r = n.getBoundingClientRect();
const shift = Math.max(0, prevBottom + 10 - r.top);
if (shift > 0) n.style.setProperty("--moco-note-shift", `${shift}px`);
prevBottom = Math.max(prevBottom, r.top + shift + r.height);
});
}
function useNoteLayout(ref: React.RefObject<HTMLElement | null>) {
React.useLayoutEffect(() => {
const scope = ref.current?.closest(".moco-notes-scope");
if (!scope) return;
let raf = 0;
const schedule = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => relayoutScope(scope));
};
schedule();
document.fonts?.ready.then(schedule).catch(() => {});
window.addEventListener("resize", schedule);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", schedule);
};
}, [ref]);
}
export function Note({ children }: NoteProps) {
const [open, setOpen] = React.useState(false);
const id = React.useId();
const wrap = React.useRef<HTMLSpanElement>(null);
useNoteLayout(wrap);
return (
<span className="moco-note">
<span className="moco-noteref" aria-hidden="true" />
<span className="moco-sidenote-wrap" ref={wrap}>
<button
type="button"
className="moco-sidenote-toggle"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen((v) => !v)}
>
<span className="moco-sidenote-num" /> {open ? "hide note" : "note"}
</button>
<span id={id} className="moco-sidenote moco-sidenote-body" data-open={open}>
<span className="moco-sidenote-num moco-desktop-only" aria-hidden="true" />
{children}
</span>
</span>
</span>
);
}
/**
* Dotted-underline term revealing a definition in a popover.
*
* The popover anchors left by default and flips to anchor right when the term
* sits in the right half of the viewport, so it can never run off the edge.
* CSS alone can't do this — it doesn't know where the inline box landed.
*/
export interface TermProps {
children: React.ReactNode;
def: React.ReactNode;
}
export function Term({ children, def }: TermProps) {
const id = React.useId();
const ref = React.useRef<HTMLSpanElement>(null);
const [flip, setFlip] = React.useState(false);
const measure = () => {
const r = ref.current?.getBoundingClientRect();
if (r) setFlip(r.left + r.width / 2 > window.innerWidth / 2);
};
return (
<span
ref={ref}
className="moco-term"
tabIndex={0}
aria-describedby={id}
onPointerEnter={measure}
onFocus={measure}
>
{children}
<span className="moco-term-pop" id={id} role="tooltip" data-flip={flip}>
{def}
</span>
</span>
);
}
components/moco/notes.css
/* ── notes scope ─────────────────────────────────────────────────────────────
Positioned, because margin notes hang off its right edge. Wrap the prose
column in this — it holds the note counter and is the only reliable
ancestor when a renderer emits headings and paragraphs as flat siblings. */
.moco-notes-scope {
position: relative;
counter-reset: moco-note;
}
/* ── sidenotes ─────────────────────────────────────────────────────────── */
.moco-note {
counter-increment: moco-note;
}
.moco-noteref {
color: var(--moco-accent-dk);
font-size: 0.72em;
vertical-align: super;
line-height: 0;
padding: 0 0.1em;
}
.moco-noteref::after {
content: counter(moco-note);
}
.moco-sidenote-num::before {
content: counter(moco-note);
color: var(--moco-accent-dk);
font-size: 0.8em;
vertical-align: super;
line-height: 0;
margin-right: 0.25em;
}
.moco-sidenote {
font-family: var(--moco-serif);
font-size: 14px;
line-height: 1.6;
color: var(--moco-muted);
}
/* The inline disclosure is the default everywhere — it needs no room and no
positioned ancestor, so it can't escape whatever contains it. */
.moco-sidenote-wrap {
display: block;
margin: 0.25rem 0 1rem;
}
.moco-sidenote-body {
display: block;
border-left: 1px solid var(--moco-line);
padding-left: 0.9rem;
margin-top: 0.5rem;
}
.moco-sidenote-body[data-open="false"] {
display: none;
}
.moco-sidenote-body .moco-desktop-only {
display: none;
}
.moco-sidenote-toggle {
font: inherit;
font-family: var(--moco-mono);
font-size: 12px;
color: var(--moco-faint);
background: none;
border: none;
border-bottom: 1px dotted var(--moco-line);
padding: 0;
cursor: pointer;
}
/* The true margin note is an upgrade: it needs the prose column's right
gutter, which only `.moco-notes-scope` provides. Outside it there is
nothing to hang off, and the note would anchor to the viewport. */
@media (min-width: 1280px) {
.moco-notes-scope .moco-sidenote-wrap {
position: absolute;
left: calc(100% + 2rem);
width: 240px;
/* --moco-note-shift is set by the component's collision pass, pushing a
note below its predecessor when two anchors sit close together. */
margin: calc(-0.4rem + var(--moco-note-shift, 0px)) 0 0;
}
.moco-notes-scope .moco-sidenote-toggle {
display: none;
}
.moco-notes-scope .moco-sidenote-body {
display: block;
border-left: 0;
padding-left: 0;
margin-top: 0;
}
.moco-notes-scope .moco-sidenote-body .moco-desktop-only {
display: inline;
}
}
/* With JS off the toggle can't fire, so never hide the note. Add `moco-no-js`
to <html> (and remove it from an inline script) to opt in. */
.moco-no-js .moco-sidenote-body[data-open="false"] {
display: block;
}
/* ── term tooltip ──────────────────────────────────────────────────────── */
.moco-term {
position: relative;
border-bottom: 1px dotted var(--moco-faint);
cursor: help;
}
.moco-term-pop {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 20;
width: max-content;
max-width: min(280px, 70vw);
background: var(--moco-bg);
border: 1px solid var(--moco-line);
border-radius: 2px;
padding: 0.55rem 0.7rem;
font-size: 12.5px;
line-height: 1.5;
color: var(--moco-muted);
opacity: 0;
pointer-events: none;
transition: opacity 150ms var(--moco-ease);
}
.moco-term-pop[data-flip="true"] {
left: auto;
right: 0;
}
.moco-term:hover .moco-term-pop,
.moco-term:focus-visible .moco-term-pop,
.moco-term:focus-within .moco-term-pop {
opacity: 1;
}
/* On narrow screens an anchored popover can't be kept on screen without JS,
so the definition becomes an inline disclosure instead — the same treatment
sidenotes get at this width. */
@media (max-width: 1023px) {
.moco-term-pop {
position: static;
display: none;
opacity: 1;
max-width: 100%;
margin: 0.4rem 0;
}
.moco-term:hover .moco-term-pop,
.moco-term:focus-within .moco-term-pop {
display: inline-block;
}
}
@media (prefers-reduced-motion: reduce) {
.moco-term-pop {
transition-duration: 0.001ms;
}
}