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

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