Skip to main content

Columns

Multi-column layout blocks for TipTap. Supports 2 and 3 columns with configurable widths, gap spacing, and mobile stacking behaviour.

Live demo

Loading playground…

Click inside a column block to reveal the layout, gap, and mobile controls in the toolbar.

Installation

npm install @tapx/columns
# or
pnpm add @tapx/columns

Usage

UI not included

Registering the extensions doesn't add any visible controls to your editor. You need to wire up UI to let users insert and manage columns. See the Static toolbar section for a ready-made component to copy, or the Bubble toolbar section for a floating alternative.

import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { columnsKit } from '@tapx/columns'

const editor = new Editor({
extensions: [StarterKit, ...columnsKit],
})

Or import the pieces individually if you need to extend them:

import { Column, Columns2, Columns3, ColumnsCommands } from '@tapx/columns'

const editor = new Editor({
extensions: [StarterKit, Column, Columns2, Columns3, ColumnsCommands],
})

Commands

CommandArgumentsDescription
insertColumnscount: 2 | 3Insert a new column block at the current position
setColumnCountcount: 2 | 3Change the column count of the block the cursor is in
setColumnsLayoutflexValues: number[]Set each column's flex ratio. [2, 1] means the first column is twice as wide as the second
setColumnsGap'tight' | 'normal' | 'wide'Set the gap between columns
setColumnsMobileStackbooleantrue = stack vertically on mobile (default), false = side by side

Layout presets

// Two columns
editor.commands.setColumnsLayout([1, 1]) // 1/2 + 1/2
editor.commands.setColumnsLayout([2, 1]) // 2/3 + 1/3
editor.commands.setColumnsLayout([1, 2]) // 1/3 + 2/3

// Three columns
editor.commands.setColumnsLayout([1, 1, 1]) // equal thirds
editor.commands.setColumnsLayout([2, 1, 1]) // 1/2 + 1/4 + 1/4
editor.commands.setColumnsLayout([1, 2, 1]) // 1/4 + 1/2 + 1/4

HTML output

Attributes with default values are omitted from the HTML to keep markup clean:

<!-- 2-column, default gap, stacks on mobile (all defaults — minimal output) -->
<div data-cols="2">
<div data-col><p>Left</p></div>
<div data-col><p>Right</p></div>
</div>

<!-- 2-column, wide gap, 2/3 + 1/3 layout, side by side on mobile -->
<div data-cols="2" data-col-gap="wide" data-col-mobile="side">
<div data-col data-col-flex="2"><p>Left</p></div>
<div data-col><p>Right</p></div>
</div>
AttributeValuesDefault
data-cols"2", "3"always present
data-colpresence markeralways present
data-col-gap"tight", "normal", "wide""normal"
data-col-mobile"side"not set — columns stack on mobile
data-col-flexany positive number1

Attributes that match their default value are not written to the HTML output, keeping the markup lean.

Extending allowed content

By default, columns accept standard block nodes (paragraph, headings, lists, blockquote, horizontal rule). To allow additional node types — for example, images — extend the Column node and spread the rest of columnsKit alongside it:

import { Column, Columns2, Columns3, ColumnsCommands } from '@tapx/columns'
import Image from '@tiptap/extension-image'

const CustomColumn = Column.extend({
content: '(paragraph | heading | bulletList | orderedList | blockquote | horizontalRule | image)+',
})

const editor = new Editor({
extensions: [StarterKit, Image, CustomColumn, Columns2, Columns3, ColumnsCommands],
})

columnsKit can't be used here because it includes the default Column — using both would register two conflicting node types. When extending Column, list the four extensions individually as shown above.

Static toolbar

@tapx/columns ships editor logic only — the UI is yours to build. The simplest starting point is a static toolbar rendered above the editor. Copy this component into your project and style it to match your design system.

Icons

For layout presets and mobile stacking, the demo above uses small CSS spans. No icon library needed.

import { useReducer } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import {
columnsKit,
isColumnsActive,
readColumnsState,
flexesMatch,
getColumnPresets,
} from '@tapx/columns'

export function Editor() {
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)

const editor = useEditor({
extensions: [StarterKit, ...columnsKit],
content: '<p>Start typing…</p>',
onTransaction: () => forceUpdate(),
})

if (!editor) return null

const inColumns = isColumnsActive(editor)
const cols = inColumns ? readColumnsState(editor) : null
const presets = cols ? getColumnPresets(cols.count) : []

return (
<div>
<div className="toolbar">
{/* Insert / switch column count */}
<button
onClick={() => inColumns
? editor.chain().focus().setColumnCount(2).run()
: editor.chain().focus().insertColumns(2).run()}
>
2 columns
</button>
<button
onClick={() => inColumns
? editor.chain().focus().setColumnCount(3).run()
: editor.chain().focus().insertColumns(3).run()}
>
3 columns
</button>

{inColumns && cols && (
<>
{/* Layout presets */}
{presets.map((preset) => (
<button
key={preset.label}
aria-pressed={flexesMatch(cols.flexValues, preset.flexes)}
onClick={() => editor.chain().focus().setColumnsLayout(preset.flexes).run()}
>
{preset.label}
</button>
))}

{/* Gap */}
{(['tight', 'normal', 'wide'] as const).map((g) => (
<button
key={g}
aria-pressed={cols.gap === g}
onClick={() => editor.chain().focus().setColumnsGap(g).run()}
>
{g}
</button>
))}

{/* Mobile stacking */}
<button
aria-pressed={cols.mobileStack}
onClick={() => editor.chain().focus().setColumnsMobileStack(true).run()}
>
Stack on mobile
</button>
<button
aria-pressed={!cols.mobileStack}
onClick={() => editor.chain().focus().setColumnsMobileStack(false).run()}
>
Side by side
</button>
</>
)}
</div>

<EditorContent editor={editor} />
</div>
)
}

Here are the icon patterns used in the demo above:

{/* Layout preset — proportional bars reflecting the actual flex ratio */}
{presets.map((preset) => (
<button key={preset.label} onClick={() => editor.chain().focus().setColumnsLayout(preset.flexes).run()}>
<span style={{ display: 'inline-flex', gap: 2, width: 26, height: 12 }}>
{preset.flexes.map((f, i) => (
<span key={i} style={{ display: 'block', flex: f, height: '100%', background: 'currentColor', borderRadius: 1 }} />
))}
</span>
</button>
))}

{/* Mobile stacking — two stacked bars */}
<button onClick={() => editor.chain().focus().setColumnsMobileStack(true).run()}>
<span style={{ display: 'inline-flex', flexDirection: 'column', gap: 2, width: 18, height: 14 }}>
<span style={{ display: 'block', width: '100%', height: 5, background: 'currentColor', borderRadius: 1 }} />
<span style={{ display: 'block', width: '100%', height: 5, background: 'currentColor', borderRadius: 1 }} />
</span>
</button>

{/* Mobile side by side — two vertical bars */}
<button onClick={() => editor.chain().focus().setColumnsMobileStack(false).run()}>
<span style={{ display: 'inline-flex', flexDirection: 'row', gap: 2, width: 18, height: 14 }}>
<span style={{ display: 'block', width: 6, height: 12, background: 'currentColor', borderRadius: 1 }} />
<span style={{ display: 'block', width: 6, height: 12, background: 'currentColor', borderRadius: 1 }} />
</span>
</button>

Gap and column count buttons work well as text labels (T / N / W and 2 / 3).

onTransaction is required

Without onTransaction: () => forceUpdate(), the toolbar computes isColumnsActive once on mount and never updates — so the columns controls will never appear when the cursor moves into a column block. onTransaction fires on every editor state change and triggers a re-render with the current selection state.

All buttons in the example already set aria-pressed — a single CSS rule covers the active state for column count, layout presets, gap, and mobile stacking at once:

.toolbar button[aria-pressed="true"] {
background: #5468d4;
color: #fff;
}

Bubble toolbar

For a better editing experience, a floating bubble that appears directly over the active columns block is the recommended approach in production.

Loading playground…

Below is the full reference implementation using @floating-ui/react. Copy it into your project and style the buttons to match your design system.

npm install @floating-ui/react
import { useEffect, useReducer } from 'react'
import { createPortal } from 'react-dom'
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/react'
import type { Editor } from '@tiptap/core'
import {
isColumnsActive,
getColumnsDomNode,
readColumnsState,
flexesMatch,
getColumnPresets,
type ColumnsState,
} from '@tapx/columns'

interface ColumnsBubbleProps {
editor: Editor
}

export function ColumnsBubble({ editor }: ColumnsBubbleProps) {
const [, forceUpdate] = useReducer((x: number) => x + 1, 0)

const { refs, floatingStyles } = useFloating({
placement: 'top',
middleware: [offset(8), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
})

useEffect(() => {
const sync = () => {
const dom = getColumnsDomNode(editor)
if (dom) refs.setReference(dom)
forceUpdate()
}
editor.on('transaction', sync)
sync()
return () => { editor.off('transaction', sync) }
}, [editor, refs])

const isActive = isColumnsActive(editor)
const cols = isActive ? readColumnsState(editor) : null
const presets = cols ? getColumnPresets(cols.count) : []

if (!isActive) return null

return createPortal(
<div ref={refs.setFloating} style={floatingStyles} className="columns-bubble">
{/* Column count */}
<button type="button" onClick={() => editor.chain().focus().setColumnCount(2).run()} aria-pressed={cols?.count === 2} aria-label="2 columns">
2
</button>
<button type="button" onClick={() => editor.chain().focus().setColumnCount(3).run()} aria-pressed={cols?.count === 3} aria-label="3 columns">
3
</button>

<span className="columns-bubble__divider" aria-hidden />

{/* Layout presets */}
{presets.map((preset) => (
<button
key={preset.label}
type="button"
aria-label={preset.label}
aria-pressed={cols ? flexesMatch(cols.flexValues, preset.flexes) : false}
onClick={() => editor.chain().focus().setColumnsLayout(preset.flexes).run()}
>
<LayoutIcon flexes={preset.flexes} />
</button>
))}

<span className="columns-bubble__divider" aria-hidden />

{/* Gap */}
{(['tight', 'normal', 'wide'] as const).map((g) => (
<button
key={g}
type="button"
aria-label={`Gap: ${g}`}
aria-pressed={cols?.gap === g}
onClick={() => editor.chain().focus().setColumnsGap(g).run()}
>
{g[0].toUpperCase()}
</button>
))}

<span className="columns-bubble__divider" aria-hidden />

{/* Mobile stacking */}
<button
type="button"
aria-label="Stack on mobile"
aria-pressed={cols?.mobileStack}
onClick={() => editor.chain().focus().setColumnsMobileStack(true).run()}
>
Stack
</button>
<button
type="button"
aria-label="Side by side on mobile"
aria-pressed={cols ? !cols.mobileStack : false}
onClick={() => editor.chain().focus().setColumnsMobileStack(false).run()}
>
Side
</button>
</div>,
document.body,
)
}

Wire it into your editor component:

<EditorContent editor={editor} />
<ColumnsBubble editor={editor} />

A minimal starting point for the bubble's CSS:

.columns-bubble {
z-index: 50;
display: flex;
align-items: center;
gap: 2px;
padding: 4px 6px;
border-radius: 6px;
border: 1px solid #e2e8f0;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}

.columns-bubble__divider {
display: block;
width: 1px;
height: 20px;
background: #e2e8f0;
margin: 0 2px;
}

/* Style active state however fits your design */
.columns-bubble button[aria-pressed="true"] {
background: #5468d4;
color: #fff;
}
RTL support

If your editor supports right-to-left content, read the dir attribute from the editor's DOM element and pass it to the bubble:

const dir = editor.view.dom.closest<HTMLElement>('[dir]')?.getAttribute('dir') ?? 'ltr'
// Add dir={dir} to the bubble's root div

CSS

The extension produces HTML with data attributes. You are responsible for styles in two places: inside the editor during authoring, and on your public website when rendering saved content.

Editor styles

Add these to the stylesheet loaded alongside your TipTap editor. The .ProseMirror scope keeps them from leaking into the rest of your page.

/* Dashed outlines so columns are visible while editing */
.ProseMirror [data-cols] {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0.75rem 0;
padding: 4px;
border: 1px dashed rgba(100, 116, 139, 0.35);
border-radius: 6px;
}

@media (min-width: 640px) {
.ProseMirror [data-cols] { flex-direction: row; }
}

.ProseMirror [data-cols][data-col-mobile="side"] { flex-direction: row; }

.ProseMirror [data-cols][data-col-gap="tight"] { gap: 0.5rem; }
.ProseMirror [data-cols][data-col-gap="wide"] { gap: 2rem; }

.ProseMirror [data-col] {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
border: 2px dashed rgba(100, 116, 139, 0.25);
border-radius: 4px;
transition: border-color 0.15s;
}

.ProseMirror [data-col]:focus-within {
border-color: #5468d4; /* swap for your accent colour */
}

.ProseMirror [data-col][data-col-flex="2"] { flex: 2; }
.ProseMirror [data-col][data-col-flex="3"] { flex: 3; }

Output styles

Add these to your public website where saved content is rendered. Adjust the values (gap sizes, breakpoint, border radius) to match your design system.

[data-cols] {
display: flex;
gap: 1rem; /* normal */
}

[data-cols][data-col-gap="tight"] { gap: 0.5rem; }
[data-cols][data-col-gap="wide"] { gap: 2rem; }

[data-col] { flex: 1; min-width: 0; }
[data-col][data-col-flex="2"] { flex: 2; }
[data-col][data-col-flex="3"] { flex: 3; }

@media (max-width: 640px) {
/* Stack by default; opt out with data-col-mobile="side" */
[data-cols]:not([data-col-mobile="side"]) {
flex-direction: column;
}
}

HTML sanitization

If you pass the editor's HTML output through a sanitizer before storing or rendering it, you must allow-list the div elements and data attributes that @tapx/columns writes — otherwise the column structure will be stripped.

DOMPurify

import DOMPurify from 'dompurify'

const clean = DOMPurify.sanitize(html, {
ADD_TAGS: ['div'],
ADD_ATTR: ['data-cols', 'data-col', 'data-col-flex', 'data-col-gap', 'data-col-mobile'],
})

sanitize-html

import sanitizeHtml from 'sanitize-html'

const clean = sanitizeHtml(html, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['div']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
div: ['data-cols', 'data-col', 'data-col-flex', 'data-col-gap', 'data-col-mobile'],
},
})

If you use a different sanitizer, the attributes to allow are always the same five: data-cols, data-col, data-col-flex, data-col-gap, and data-col-mobile.