Documentation menu

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

Ask Claude (MCP)

With the MCP server configured, just ask:

Claude
add the moco notes component

shadcn CLI

Terminal
npx shadcn@latest add https://mocoui.site/r/notes.json

Manual

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

Needs 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;
  }
}