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;
}
export function Note({ children }: NoteProps) {
const [open, setOpen] = React.useState(false);
const id = React.useId();
return (
<span className="moco-note">
<span className="moco-noteref" aria-hidden="true" />
<span className="moco-sidenote-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;
margin: -0.4rem 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;
}
}