← All @molecule/* packages · App templates

@molecule/app-margin-notes-react

Feature · margin-notes · App (browser) · v1.0.1 · Apache-2.0

Long-form reading layout with notes beside each paragraph: aligned notes column, margin marks, two-way hover, switchable note kinds, and a phone bar that follows the reader

npm install @molecule/app-margin-notes-react

npm · Source on GitHub

How it works

@molecule/app-margin-notes-react is a ready-made margin-notes feature for the app (browser) side. It composes the core interfaces it needs, so it works with whichever providers your app has bonded.

import { MarginNotes } from '@molecule/app-margin-notes-react'

// blocks: your rendered paragraphs, in order; notes: what goes beside them.
;<MarginNotes
  blocks={[
    { id: 'p1', content: <p>Every post carries a map of who wrote what.</p>, noteIds: ['s1'] },
    {
      id: 'p2',
      content: <p>The map is built from the session export.</p>,
      noteIds: ['s1', 'q1'],
      marked: true,
    },
  ]}
  notes={[
    { id: 's1', kind: 'summary', content: <p>How the map is made.</p> },
    { id: 'q1', kind: 'prompt', content: <p>“explain the map” — claude-opus-4</p> },
  ]}
  kinds={[
    {
      id: 'summary',
      label: t('post.summaries', undefined, { defaultValue: 'Summaries' }),
      defaultOn: true,
    },
    {
      id: 'prompt',
      label: t('post.prompts', undefined, { defaultValue: 'Prompts' }),
      defaultOn: false,
      panel: 'tap',
    },
  ]}
  markLabel={t('post.aiWritten', undefined, { defaultValue: 'Written with AI' })}
/>

Works with: @molecule/app-react, @molecule/app-ui, @molecule/app-ui-react

Reference

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Margin notes: long-form text with notes beside each paragraph — annotated essays, commentary, and posts that show which paragraphs an AI wrote and the prompt behind each.

Desktop: a large-type prose column and a narrower notes column to its right; each note lines up with the paragraph (or run of paragraphs) it belongs to and appears once. Hovering or focusing a paragraph emphasises its notes; hovering or focusing a note tints the paragraphs it covers. A paragraph can carry a small mark in its left margin. Note kinds (say, "Summaries" and "Prompts") each get a real switch. Phone: the notes column gives way to a bar fixed to the bottom that shows the notes for the section being read and follows the reader; tapping a paragraph pins its notes, tapping again lets go; the switches live in the bar. A document with no notes renders centred prose at full measure with no notes column.

Quick Start

import { MarginNotes } from '@molecule/app-margin-notes-react'

// blocks: your rendered paragraphs, in order; notes: what goes beside them.
;<MarginNotes
  blocks={[
    { id: 'p1', content: <p>Every post carries a map of who wrote what.</p>, noteIds: ['s1'] },
    {
      id: 'p2',
      content: <p>The map is built from the session export.</p>,
      noteIds: ['s1', 'q1'],
      marked: true,
    },
  ]}
  notes={[
    { id: 's1', kind: 'summary', content: <p>How the map is made.</p> },
    { id: 'q1', kind: 'prompt', content: <p>“explain the map” — claude-opus-4</p> },
  ]}
  kinds={[
    {
      id: 'summary',
      label: t('post.summaries', undefined, { defaultValue: 'Summaries' }),
      defaultOn: true,
    },
    {
      id: 'prompt',
      label: t('post.prompts', undefined, { defaultValue: 'Prompts' }),
      defaultOn: false,
      panel: 'tap',
    },
  ]}
  markLabel={t('post.aiWritten', undefined, { defaultValue: 'Written with AI' })}
/>

Type

feature

Installation

npm install @molecule/app-margin-notes-react @molecule/app-react @molecule/app-ui @molecule/app-ui-react react
npm install -D @types/react

API

Interfaces

MarginNote

A note shown beside the block(s) it belongs to.

interface MarginNote {
  /** Stable id, referenced from {@link MarginNotesBlock.noteIds}. */
  id: string
  /** The kind this note belongs to ({@link MarginNoteKind.id}), for its switch. */
  kind: string
  /** The rendered note. */
  content: ReactNode
}

MarginNoteKind

A kind of note the reader can show or hide with a switch.

interface MarginNoteKind {
  /** Stable id, referenced from {@link MarginNote.kind}. */
  id: string
  /** The switch's visible label, already translated (e.g. "Summaries"). */
  label: string
  /** Whether this kind shows before the reader touches its switch. Default `true`. */
  defaultOn?: boolean
  /**
   * How this kind reaches the phone panel. `'follow'` (default): the panel
   * shows it for the section being read, following the reader. `'tap'`: only
   * when the reader taps a block it belongs to (and gone on the second tap) —
   * for notes about one paragraph rather than the section.
   */
  panel?: 'follow' | 'tap'
}

MarginNotesBlock

One block of the text — usually a paragraph or heading from your markdown renderer. Blocks render in order, in the prose column.

interface MarginNotesBlock {
  /** Stable id, unique within the document (used as the DOM anchor). */
  id: string
  /** The rendered block (e.g. `<p>…</p>`). */
  content: ReactNode
  /** Ids of the notes that belong to this block (see {@link MarginNote}). */
  noteIds?: string[]
  /**
   * Draw a small mark in this block's left margin — the one permanent sign on
   * the prose itself (e.g. "written with AI"). Unmarked blocks carry none.
   */
  marked?: boolean
}

MarginNotesProps

<MarginNotes> props.

interface MarginNotesProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
  /** The text, in reading order. */
  blocks: MarginNotesBlock[]
  /** Every note the blocks refer to. A document with none renders centred prose with no gutter. */
  notes?: MarginNote[]
  /** The note kinds, in the order their switches appear. Kinds absent here are always shown. */
  kinds?: MarginNoteKind[]
  /** Accessible name of the margin mark, already translated (e.g. "Written with AI"). */
  markLabel?: string
  /** Width of the prose column (a CSS length). Default `38rem`. */
  measure?: string
  /** Width of the notes column (a CSS length). Default `18rem`. */
  gutterWidth?: string
  /** Controlled: the ids of the kinds currently shown. Omit to let the component own it. */
  shownKinds?: string[]
  /** Called with the new list of shown kind ids whenever the reader flips a switch. */
  onShownKindsChange?: (shown: string[]) => void
}

MarginNotesRow

One row of the layout: a run of blocks and the notes shown beside it.

interface MarginNotesRow {
  /** The row's key: the id of its first block. */
  id: string
  /** The blocks in this row, in order. */
  blocks: MarginNotesBlock[]
  /** Notes shown beside this row — each note appears in exactly one row, its first. */
  notes: MarginNote[]
  /** Every note id any block in this row refers to (for the phone panel). */
  noteIds: string[]
}

Functions

buildRows(blocks, notes)

Group blocks into layout rows and place each note once.

A row is a run of consecutive blocks that refer to exactly the same set of notes (a paragraph run sharing one note), or a run of consecutive blocks with no notes. Each note is shown beside the FIRST row that refers to it and never again, so a note covering several paragraphs — even ones that are not next to each other — appears once on the page.

function buildRows(blocks: MarginNotesBlock[], notes?: MarginNote[]): MarginNotesRow[]
  • blocks — The text, in reading order.
  • notes — Every note the blocks refer to; ids with no note are ignored.

Returns: The rows, in reading order.

defaultShownKinds(kinds)

The kinds shown before the reader touches a switch.

function defaultShownKinds(kinds?: { id: string; defaultOn?: boolean }[]): string[]
  • kinds — The switchable kinds.

Returns: The ids of the kinds that default on.

MarginNotes(props)

Long-form text with notes beside each paragraph.

Desktop: a prose column and, to its right, a narrower notes column; each note lines up with the block(s) it belongs to and appears once. Phone: the notes column disappears and a bar fixed to the bottom shows the notes for the section being read (tap a block to pin its notes, tap again to let go), with the kind switches in the bar. Every default — which kinds show, which surface a width gets — is decided in the first render and by CSS, so server-rendered or prerendered HTML is right before any JavaScript runs.

function MarginNotes(props: MarginNotesProps): JSX.Element
  • props — See {@link MarginNotesProps}.

Returns: The layout.

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-react ^1.0.1
  • @molecule/app-ui ^1.2.0
  • @molecule/app-ui-react ^1.0.1
  • react ^18.0.0 || ^19.0.0

Runtime Dependencies

  • @molecule/app-react

  • @molecule/app-ui

  • @molecule/app-ui-react

  • react

  • Defaults are right before JavaScript runs. Which kinds show comes from kinds[].defaultOn in the FIRST render (a hidden note carries the HTML hidden attribute), and the desktop/phone split is CSS (cm.hiddenBelow('md') / cm.hiddenFrom('md')). Prerender the page with renderToString and a kind that defaults off is already absent; do NOT toggle defaults in a useEffect of your own.

  • One note, one place. A note shows beside the FIRST row that refers to it. Give consecutive paragraphs that share a note the same noteIds and they form one row with the note beside them once. A paragraph whose noteIds differ from its neighbour's starts a new row.

  • The mark is drawn on the block, as an absolutely-placed dot inside the block's own wrapper (hidden on phones, where there is no margin). At rest a marked block is typeset exactly like an unmarked one.

  • Pass rendered blocks, not markdown. Split your renderer's output into top-level blocks (one per paragraph/heading/list) and give each a stable id; the component adds no markup inside them.

  • Section notes follow the reader; paragraph notes wait for a tap. On a phone the panel shows the kinds with panel: 'follow' (the default) for the section in view. Give a kind that is about ONE paragraph (a prompt, a citation) panel: 'tap': it shows when that paragraph is tapped and goes on the second tap.

  • Controlled or not. Pass shownKinds + onShownKindsChange to own the switches' state (e.g. to remember it); otherwise the component does.

  • The phone bar reserves space at the end of the text so it never covers the last paragraph. UI strings come from the companion locale bond @molecule/app-locales-margin-notes; kind labels and markLabel are yours to translate.

Translations

Translation strings are provided by @molecule/app-locales-margin-notes.