Outline Editor
A nested outliner: Enter adds a bullet, Tab indents, Shift+Tab outdents, and parents fold to hide their children.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/outline-editor.json'use client'
import { useEffect, useRef, useState } from 'react'
import './outline-editor.css'
export interface Item { id: number; text: string; depth: number }
const DEF: Item[] = [
{ id: 1, text: 'Launch plan', depth: 0 }, { id: 2, text: 'Finalise pricing page', depth: 1 }, { id: 3, text: 'Annual discount copy', depth: 2 },
{ id: 4, text: 'Record demo video', depth: 1 }, { id: 5, text: 'Press list', depth: 0 }, { id: 6, text: 'Draft embargo note', depth: 1 },
]
let uid = 100
export interface OutlineEditorProps {
defaultItems?: Item[]
numbered?: boolean
onChange?: (items: Item[]) => void
fill?: 'dark' | 'light'
className?: string
}
/** Keyboard-first nested outline editor. */
export function OutlineEditor({ defaultItems = DEF, numbered = false, onChange, fill = 'dark', className }: OutlineEditorProps) {
const [items, setItems] = useState(defaultItems)
const [folded, setFolded] = useState<number[]>([])
const [focus, setFocus] = useState<number | null>(null)
const [cur, setCur] = useState<number>(defaultItems[0]?.id ?? 0)
const ref = useRef<HTMLUListElement>(null)
useEffect(() => { if (focus === null) return; ref.current?.querySelector<HTMLInputElement>(`input[data-id="${focus}"]`)?.focus(); setFocus(null) }, [focus])
const put = (n: Item[]) => { setItems(n); onChange?.(n) }
const kids = (i: number) => { let j = i + 1; while (j < items.length && items[j].depth > items[i].depth) j++; return j - i - 1 }
const hidden = new Set<number>()
items.forEach((it, i) => { if (folded.includes(it.id) && !hidden.has(it.id)) for (let j = i + 1; j <= i + kids(i); j++) hidden.add(items[j].id) })
const idx = (id: number) => items.findIndex((x) => x.id === id)
const indent = (id: number, d: number) => { const i = idx(id); if (i < 0) return; const max = i === 0 ? 0 : items[i - 1].depth + 1; const nd = Math.max(0, Math.min(max, items[i].depth + d)); const n = items.length; const end = i + 1 + kids(i); put(items.map((x, j) => (j >= i && j < end && j < n ? { ...x, depth: x.depth + (nd - items[i].depth) } : x))); setFocus(id) }
const moveBy = (id: number, d: number) => { const i = idx(id); const j = i + d; if (j < 0 || j >= items.length) return; const n = [...items]; [n[i], n[j]] = [n[j], n[i]]; put(n); setFocus(id) }
const addAfter = (id: number) => { const i = idx(id); const it = { id: ++uid, text: '', depth: i >= 0 ? items[i].depth : 0 }; const n = [...items]; n.splice(i + 1, 0, it); put(n); setFocus(it.id); setCur(it.id) }
const key = (e: React.KeyboardEvent<HTMLInputElement>, it: Item) => {
if (e.key === 'Enter') { e.preventDefault(); addAfter(it.id) }
else if (e.key === 'Tab') { e.preventDefault(); indent(it.id, e.shiftKey ? -1 : 1) }
else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); moveBy(it.id, e.key === 'ArrowUp' ? -1 : 1) }
else if (e.key === 'Backspace' && !it.text && items.length > 1) { e.preventDefault(); const i = idx(it.id); const prev = items[Math.max(0, i - 1)]; put(items.filter((x) => x.id !== it.id)); setFocus(prev.id === it.id ? items[1].id : prev.id) }
}
const counters: number[] = []
const nums = items.map((it) => { counters.length = it.depth + 1; counters[it.depth] = (counters[it.depth] ?? 0) + 1; return counters.join('.') })
const visible = items.filter((x) => !hidden.has(x.id))
return (
<div className={['sd-otl', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-otl__h">Notes<span>{items.length} bullets</span></div>
<ul ref={ref} aria-label="Outline">{visible.map((it) => { const i = idx(it.id); const k = kids(i); const f = folded.includes(it.id); return (
<li key={it.id} style={{ paddingLeft: it.depth * 16 }}>
{k ? <button type="button" className="sd-otl__fold" aria-expanded={!f} aria-label={`${f ? 'Expand' : 'Collapse'} ${it.text || 'bullet'}`} onClick={() => setFolded(f ? folded.filter((x) => x !== it.id) : [...folded, it.id])}><svg viewBox="0 0 10 10" aria-hidden><path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" strokeWidth="1.6" /></svg></button> : <span />}
<span className="sd-otl__dot" aria-hidden>{numbered ? nums[i] : it.depth % 2 ? '◦' : '•'}</span>
<input data-id={it.id} aria-label={`Bullet, level ${it.depth + 1}`} value={it.text} placeholder="Type…" onFocus={() => setCur(it.id)} onChange={(e) => put(items.map((x) => (x.id === it.id ? { ...x, text: e.target.value } : x)))} onKeyDown={(e) => key(e, it)} />
{f ? <small>+{k}</small> : <span />}
</li>) })}</ul>
<div className="sd-otl__bar" onMouseDown={(e) => e.preventDefault()}>
<button type="button" aria-label="Outdent" onClick={() => indent(cur, -1)}>⇤</button>
<button type="button" aria-label="Indent" onClick={() => indent(cur, 1)}>⇥</button>
<button type="button" aria-label="Move up" onClick={() => moveBy(cur, -1)}>↑</button>
<button type="button" aria-label="Move down" onClick={() => moveBy(cur, 1)}>↓</button>
<button type="button" onClick={() => addAfter(cur)}>+ Bullet</button>
</div>
</div>
)
}.sd-otl { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-otl[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-otl :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-otl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-otl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-otl { width: min(100%, 340px); padding: 12px 12px 10px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 8px; }
.sd-otl__h { font-size: 13px; font-weight: 600; padding: 0 4px; display: flex; justify-content: space-between; }
.sd-otl__h span { font-size: 11px; color: var(--mute); font-weight: 400; }
.sd-otl ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; max-height: 260px; overflow-y: auto; }
.sd-otl li { display: grid; grid-template-columns: 18px 16px minmax(0, 1fr) auto; align-items: center; gap: 2px; border-radius: 7px; animation: sd-otl-in 180ms ease-out; }
@keyframes sd-otl-in { from { opacity: 0; transform: translateY(-3px); } }
.sd-otl li:focus-within { background: var(--soft); }
.sd-otl__fold { width: 18px; height: 22px; color: var(--mute) !important; display: grid; place-items: center; }
.sd-otl__fold svg { width: 10px; height: 10px; transition: transform 150ms; }
.sd-otl__fold[aria-expanded='false'] svg { transform: rotate(-90deg); }
.sd-otl__dot { font-size: 11px; color: var(--mute); text-align: center; font-variant-numeric: tabular-nums; }
.sd-otl li input { height: 28px; border: 0; background: none; color: var(--fg); font: inherit; font-size: 13px; outline: 0; padding: 0 2px; min-width: 0; }
.sd-otl li small { font-size: 10.5px; color: var(--mute); padding-right: 6px; }
.sd-otl__bar { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; gap: 4px; padding-top: 8px; border-top: 1px solid var(--line); }
.sd-otl__bar button { height: 30px; border-radius: 8px; font-size: 11.5px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-otl__bar button:last-child { padding: 0 12px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .sd-otl *, .sd-otl *::before, .sd-otl *::after { animation: none !important; transition: none !important; } }Overview
Bullets with children get a fold toggle and a count of what is hidden. Backspace on an empty bullet removes it and returns to the one above.
Alt with the arrow keys moves a bullet up or down; touch users get indent, outdent and move buttons for the focused row.
When to use
- Note apps, meeting agendas, planning tools.
When not to use
- Rich documents with headings and media: use a full editor.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/outline-editor.json$
npx shadcn@latest add https://sundaeui.dev/r/outline-editor.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/outline-editor.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/outline-editor.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy outline-editor.tsx and outline-editor.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { OutlineEditor } from '@/components/sundae/outline-editor'
<OutlineEditor />Props
| Prop | Type | Default |
|---|---|---|
defaultItemsStarting bullets with depth. | Item[] | — |
onChangeCalled on every edit. | (items: Item[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The outline is a list; each bullet input is named by its depth.
- Fold toggles are buttons with expanded state.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-otl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter
- New bullet
- Tab / Shift+Tab
- Indent / outdent
- Alt + Up/Down
- Move bullet
- Backspace on empty
- Delete bullet
FAQ
- Is Outline Editor free?
- Yes. Outline Editor is free for personal and commercial projects, with full source on this page.
- How do I install Outline Editor?
- Run npx shadcn@latest add https://sundaeui.dev/r/outline-editor.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Outline Editor support keyboard and screen readers?
- The outline is a list; each bullet input is named by its depth. Fold toggles are buttons with expanded state.
- Does Outline Editor respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Checkbox Tree
Forms · Refined- Pro
Shortcut Recorder
Forms · Refined Markdown Editor
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined