Content blocks

Block model

Every piece of a page is a block. `@krackedui/motion` models a page as a `Block[]` tree and renders it with consistent block spacing; the same tree drives the read-only renderer and the editor.

Project plan
A short page with bold, italic and inline code.
Goals
•
Ship the first draft
◦
Outline sections
•
Collect feedback
Review the outline
Share with the team
Notes
💡
Callouts highlight a key point.
Simple things should be simple.
More details

The Block type

A block has an id, a type, optional rich text, optional nested children, an optional color and type-specific props. Nesting is real nesting: list items, toggles, quotes and callouts hold their sub-blocks in children; columns are a column_list whose children are column blocks.

types.ts
interface Block {
  id: string;
  type: BlockType;
  text?: RichText;
  children?: Block[];
  color?: BlockColor;
  props?: BlockProps;
}

type BlockType =
  | 'text' | 'heading_1' | 'heading_2' | 'heading_3' | 'heading_4'
  | 'bulleted_list' | 'numbered_list' | 'to_do' | 'toggle'
  | 'quote' | 'callout' | 'divider' | 'code' | 'equation'
  | 'image' | 'bookmark' | 'table' | 'column_list' | 'column'
  | 'table_of_contents' | 'page';

Which props apply depends on the type:

  • heading_*: toggleable, open
  • toggle: open · to_do: checked
  • callout: icon (emoji)
  • code: language (the code itself is text)
  • equation: expression (TeX)
  • image: src, caption, width
  • bookmark: url, title, description, thumbnail, favicon
  • table: rows (RichText cells), hasHeaderRow, hasHeaderColumn, columnWidths
  • column: ratio (0..1 width share) · page: icon (the title is text)

Rich text

RichText is a list of runs. Each run has text, optional marks (bold, italic, underline, strike, code, equation, link, color) and optionally a mention ({ kind: "page" | "user" | "date", label, icon? }) that renders as a non-editable token.

rich-text.ts
const text: RichText = [
  { text: 'Read the ' },
  { text: 'brief', marks: { bold: true, link: 'https://example.com' } },
  { text: ' with ' },
  { text: 'Alex', mention: { kind: 'user', label: 'Alex' } },
  { text: ' before ' },
  { text: 'Friday', mention: { kind: 'date', label: 'Friday' } },
];

Colors

BlockColor is "default", one of nine color names (gray, brown, orange, yellow, green, blue, purple, pink, red) for colored text, or <name>_background for a tinted background. The same values work on a block (block.color) and on a run of text (marks.color). Text colors use <c>-text-secondary, backgrounds <c>-bg-secondary (callouts use the lighter <c>-bg-primary).

Blue text block
Yellow background block
Runs can be red or highlighted.

Helpers

  • rt(text, marks?) builds rich text from a string (rt("") is []).
  • plainText(richText) joins the runs back into a string.
  • blockId() returns a short unique id for new blocks.
  • blockTypeNames maps each type to its display label ("To-do list", "Toggle list"…); isListLike, isHeading, textBlockTypes classify types.
  • collectHeadings(blocks) returns the headings used by the table of contents.
  • newBlock(kind) and the blockTree helpers (findBlock, insertIn, removeIn, indentIn…) come from the editor entry.

BlockContent

Block sizes are relative to the 16px / 1.5 content type (headings, code and inline code use em). BlockRenderer and Editor set it for you; when you render individual blocks on their own, wrap them in BlockContent, otherwise they inherit whatever font size surrounds them.

/app/page.tsx
import { BlockContent, Heading, TextBlock, rt } from '@krackedui/motion';

<BlockContent>
  <Heading level={2} text={rt('Section')} />
  <TextBlock text={rt('Body copy at 16px.')} />
</BlockContent>

Usage

  • Use BlockRenderer to show stored Block[] content read-only (published pages, previews, help articles).
  • Use Editor when people write or rearrange the content; both share the same Block[] model.
  • Wrap individual blocks in BlockContent when you render them outside BlockRenderer or Editor, so they get the 16px content type.
  • Don’t use blocks for app chrome (forms, toolbars, settings); use the UI components instead.

Installation

Install the package (React 19):

terminal
npm install @krackedui/motion

Import the styles once, in your app entry or root layout:

/src/main.tsx
import '@krackedui/motion/styles.css';

Use it anywhere inside your app's <Theme> (see Installation):

/src/BlockmodelExample.tsx
import { BlockRenderer, rt, type Block } from '@krackedui/motion';

const doc: Block[] = [
  { id: 'h1', type: 'heading_1', text: rt('Project plan') },
  {
    id: 'p1',
    type: 'text',
    text: [
      { text: 'A short page with ' },
      { text: 'bold', marks: { bold: true } },
      { text: ', ' },
      { text: 'italic', marks: { italic: true } },
      { text: ' and ' },
      { text: 'inline code', marks: { code: true } },
      { text: '.' },
    ],
  },
  { id: 'toc', type: 'table_of_contents' },
  { id: 'h2', type: 'heading_2', text: rt('Goals') },
  {
    id: 'l1',
    type: 'bulleted_list',
    text: rt('Ship the first draft'),
    children: [{ id: 'l1a', type: 'bulleted_list', text: rt('Outline sections') }],
  },
  { id: 'l2', type: 'bulleted_list', text: rt('Collect feedback') },
  { id: 't1', type: 'to_do', text: rt('Review the outline'), props: { checked: true } },
  { id: 't2', type: 'to_do', text: rt('Share with the team') },
  { id: 'h3', type: 'heading_3', text: rt('Notes') },
  { id: 'c1', type: 'callout', text: rt('Callouts highlight a key point.'), props: { icon: '💡' }, color: 'gray_background' },
  { id: 'q1', type: 'quote', text: rt('Simple things should be simple.') },
  { id: 'd1', type: 'divider' },
  { id: 'tg', type: 'toggle', text: rt('More details'), props: { open: false }, children: [{ id: 'tgc', type: 'text', text: rt('Hidden until the toggle opens.') }] },
];

export function Page() {
  return <BlockRenderer blocks={doc} />;
}

API Reference

BlockRenderer

Renders a Block[] tree read-only with the block rhythm, list markers and numbering.

PropTypeDefault
blocks
Block[]
renderLeaf
(block: Block, p: LeafRenderProps) => ReactNode
wrapBlock
(block: Block, node: ReactElement, ctx: BlockRenderContext) => ReactNode
onBlockChange
(id: string, props: Partial<BlockProps>) => void
renderCell
(block: Block, row: number, col: number, className: string) => ReactNode
renderEquationEditor
(block: Block) => ReactNode
onEquationEdit
(block: Block) => void
onEmptyToggleClick
(block: Block) => void
className
string

BlockContent

Content container for standalone blocks: flex flex-col, 16px / 1.5.

PropTypeDefault
children
ReactNode
className
string

TextMarks

Inline formatting of one run.

PropTypeDefault
bold
boolean
italic
boolean
underline
boolean
strike
boolean
code
boolean
equation
boolean
link
string
color
BlockColor

Mention

PropTypeDefault
kind
"page""user""date"
label
string
icon
string
"📄"

Accessibility

  • The renderer outputs plain flowing content: headings are visual (styled divs); give the page a real h1 (see Page title) if you need a document outline.
  • Toggles and to-dos stay interactive in read-only mode: they toggle locally unless you handle onBlockChange.
  • Each rendered block carries data-block-id, which the table of contents uses to scroll to headings.
© 2026 Motion. Internal documentation.