Skip to content

🚧 Overlay Customization

The overlay system provides autocomplete, suggestions, and contextual menus when users type trigger characters. Markput includes a default OverlayList component — one list for both jobs — but you can fully customize it to match your needs.

Overlays appear when users type a trigger character (e.g., @, /, #):

User types '@'
Overlay appears with suggestions
User selects 'Alice'
Text becomes '@[Alice]'

Everything typed after the trigger and left of the caret is the query, spaces and punctuation included, up to the next line break or a second trigger — so a multi-word entry (To-do list, Table of contents) can be typed in full. A query nothing matches simply offers nothing: the list is empty, the built-in overlay paints nothing, and every key goes back to the editor.

Markput includes a built-in OverlayList component, and it is what a trigger option resolves to when it names no Overlay of its own. What it offers depends on the matched option and on nothing else: an option that declares overlay.data offers that data, and an option that declares none offers the ROW MENU (see below). One list, one keyboard, either way.

import {MarkedInput} from '@markput/react'
import {useState} from 'react'
function BasicSuggestions() {
const [value, setValue] = useState('Type @ to mention someone')
return (
<MarkedInput
value={value}
onChange={setValue}
Mark={props => <span>{props.value}</span>}
options={[
{
markup: '@[__value__]',
overlay: {
trigger: '@',
data: ['Alice', 'Bob', 'Charlie', 'Diana'],
},
},
]}
/>
)
}

Features:

  • Keyboard navigation (↑↓)
  • Filtering as you type
  • Enter to select the highlighted row — the FIRST one until an arrow moves it, so Enter alone finishes the gesture and never leaves the trigger text in the document
  • Esc to close
  • Click to select

A row of data may be a string or {value, meta?, label?}. The object form is what a list with an id behind it needs — the __meta__ half of @[__value__](__meta__) — so a mention picker no longer has to abandon the built-in overlay and write its own component:

{
markup: '@[__value__](__meta__)',
overlay: {
trigger: '@',
data: [
{value: 'Sarah Chen', meta: 'sarah.chen'},
{value: 'Marcus Kane', meta: 'marcus.kane'},
],
},
}

Filtering matches the LABEL and nothing else — label when given, otherwise value — so an id the user cannot see never matches a query. A bare string still writes the row’s index as its meta, because a label is the only identity it has.

A / menu is not a custom overlay: an option that declares a menu IS in the menu, and each adapter ships the paint.

const options = [
{overlay: {trigger: '/'}},
{markup: '# __slot__', row: {Component: 'h1'}, menu: {label: 'Heading 1', keywords: ['h1', 'title']}},
{markup: '- __slot__', row: {Component: 'li', continues: true}, menu: {label: 'Bulleted list'}},
{markup: '> __slot__', row: {Component: 'blockquote'}, menu: {label: 'Quote'}},
]

That is the whole wiring, and it names no component: the / option carries no markup of its own — it exists to own the trigger — declares no overlay.data, and therefore resolves to the built-in list showing the row menu. What a choice writes is the ROW KIND the chosen entry names, by click or by ↑↓ and Enter.

MenuSpec field Meaning
label What the row shows, and the text the query is ranked against
keywords Extra query terms that never appear on screen
meta Seeds the meta of the row this entry writes
text Seeds the body of the row this entry writes

The best answer is the first row, and Enter picks the first row. The query ranks rather than merely filters: an exact label first, then a label the query is a prefix of, then a label holding it anywhere — and only then the same three over keywords, because a term the user cannot see must not outrank one they are reading. Declaration order decides inside a band, and decides the whole list before the first character is typed. Without ranking, /table committed Table of contents on the first try because that option happened to be declared earlier.

An entry with no markup is the way back to plain text. Every other entry names a kind to turn INTO; the paragraph is the one kind no option can declare, because it is slots.paragraph. An option carrying a menu and no markup names it, so choosing that entry un-types the caret’s row:

const options = [
{overlay: {trigger: '/'}},
{menu: {label: 'Text', keywords: ['paragraph', 'plain']}},
{markup: '# __slot__', row: {Component: 'h1'}, menu: {label: 'Heading 1'}},
]

Core ships no label for it: what it is called, and where it sits in the list, is yours. A markup that IS declared and compiles to no row kind still refuses — that is a typo, not a request.

Two gestures, one splice. On a row holding nothing but the trigger the entry INSERTS: the row becomes that kind, seeded from menu.text/menu.meta. On a row that already has text it CONVERTS: 'plain row' + / + Heading 1 emits '# plain row', and the seeds are not applied, because a turn-into must not discard what the user typed. Both run RowNode.turnInto(option, {text}) once — a single splice, which is what controlled mode requires of a gesture that removes a span and retypes a row at the same time.

Which gesture it is is not published, because nothing paints it: choose decides it from the caret row’s own body and no menu component asks. An entry that wants to say “Turn into” needs core to answer, so the member comes back with the reader that needs it and not before.

Replacing OverlayList. A consumer’s own list reads the same things and still writes no filtering and no insert logic. activate() is what buys the keyboard — arrows move active, Enter chooses the row it names — and it is opt-in so an overlay that is not a list never swallows those keys. A list with NO rows claims nothing, so a query that matches nothing leaves Enter to the row split:

function MyMenu() {
const {rows, active, activate, choose, style, ref} = useOverlay<HTMLUListElement>()
useEffect(activate, [activate])
if (rows.length === 0) return null
return (
<ul ref={ref} style={{position: 'absolute', ...style}}>
{rows.map((row, index) => (
<li
key={row.label}
style={index === active ? {background: '#cce9ff'} : undefined}
onMouseDown={event => event.preventDefault()}
onClick={() => choose(row.pick)}
>
{row.label}
</li>
))}
</ul>
)
}

style is a viewport position (position: fixed), and it is not simply “under the caret”: a popup that would not fit below the anchor opens ABOVE it, and one that fits neither way is clamped inside the viewport rather than left hanging off it. The same rule positions the row menu the grip opens.

The flip needs the popup’s own SIZE, which nothing knows until it has mounted — so style is evaluated twice for a newly opened overlay: once at the anchor, then again, fitted, in the same commit that attached the ref. Attach ref or you get no flip: it is how core learns how big your overlay is, as well as how outside-click detection finds it.

The popup, its highlighted row, the gutter buttons, the drop indicator and the refusal tint are painted from @markput/core/styles.module.css, and every colour in it is read through a custom property whose fallback is the shipped default. Declare any of them on an ancestor — no CSS-module class name is involved, and an editor nobody themes looks exactly as it did.

Custom property Default Paints
--markput-popup-background white Popup background
--markput-popup-border #ccc Popup border
--markput-popup-text #000 Popup text
--markput-popup-shadow 0 3px 6px -2px rgba(0,0,0,.6) Popup shadow
--markput-popup-item-background #cce9ff Hovered / highlighted row
--markput-popup-item-text #2589f5 Hovered / highlighted row
--markput-grip-color #9ca3af Both gutter buttons
--markput-drop-indicator #3b82f6 The row drop line
--markput-row-selected rgb(35 131 226 / 28%) The row selection overlay
--markput-row-refused rgb(235 87 87 / 26%) The refused-gesture tint
.my-dark-page {
--markput-popup-background: #252525;
--markput-popup-border: rgb(255 255 255 / 13%);
--markput-popup-text: rgb(255 255 255 / 82%);
--markput-popup-item-background: rgb(255 255 255 / 4%);
--markput-popup-item-text: rgb(255 255 255 / 82%);
}

Build custom overlays with the useOverlay() hook:

import {useOverlay} from '@markput/react'
function CustomOverlay() {
const overlay = useOverlay()
return <div>Custom overlay</div>
}
Property Type Description
style {left, top} Viewport position, already fitted (see above)
close() function Close the overlay
select() function Insert a mark
choose() function The one accept path; {option} retypes the row
rows readonly OverlayRow[] The list on offer, already narrowed by the query
active number Index of the highlighted row; the FIRST by default
activate() function Bind ↑↓/Enter; returns the unbind
match OverlayMatch Match details (value, source, trigger)
ref RefObject Ref for outside click detection

Complete interface:

interface OverlayHandler {
style: {
left: number // X coordinate
top: number // Y coordinate
}
close: () => void
select: (value: {value: string; meta?: string}) => void
/** `{option}` turns the caret's row into that option's kind; `{value, meta}` is `select`. */
choose: (pick: OverlayPick) => boolean
/** The matched option's `overlay.data`, or the row menu when it declares none. */
rows: readonly OverlayRow[]
/** Index into `rows` of the highlighted row — the first one until an arrow moves it. */
active: number
/** Bind ↑↓/Enter to the editing host, and return the unbind. Opt-in. */
activate: () => () => void
match: {
value: string // Typed text after trigger
source: string // Full matched text including trigger
span: string // Text of the node the match was found in
node: Node // DOM node
range: Anchors // The span `select()` replaces, as node anchors
option: Option // Matched option config
}
ref: RefObject<HTMLElement>
}
import {useOverlay} from '@markput/react'
function SimpleListOverlay() {
const {select} = useOverlay()
const items = ['Apple', 'Banana', 'Cherry']
return (
<ul className="overlay">
{items.map(item => (
<li key={item} onClick={() => select({value: item})}>
{item}
</li>
))}
</ul>
)
}
// Usage
;<MarkedInput Overlay={SimpleListOverlay} options={[{overlay: {trigger: '@'}}]} />

Position the overlay at the caret:

function PositionedOverlay() {
const {style, select} = useOverlay()
const items = ['Item 1', 'Item 2', 'Item 3']
return (
<div
style={{
position: 'absolute',
left: style.left,
top: style.top,
background: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
zIndex: 1000,
}}
>
{items.map(item => (
<div key={item} onClick={() => select({value: item})} style={{padding: '8px 12px', cursor: 'pointer'}}>
{item}
</div>
))}
</div>
)
}

Filter based on typed text:

function FilteredOverlay() {
const {select, match, close} = useOverlay()
const allItems = ['Alice', 'Bob', 'Charlie', 'Diana']
// `match` is undefined while no trigger is open
const query = match?.value ?? ''
// Filter items based on typed text
const filtered = allItems.filter(item => item.toLowerCase().includes(query.toLowerCase()))
if (filtered.length === 0) {
return (
<div className="overlay">
<div className="empty">No results</div>
</div>
)
}
return (
<ul className="overlay">
{filtered.map(item => (
<li key={item} onClick={() => select({value: item})}>
{item}
</li>
))}
</ul>
)
}

Include metadata when selecting:

function UserOverlay() {
const {select} = useOverlay()
const users = [
{id: '1', name: 'Alice', avatar: '👩'},
{id: '2', name: 'Bob', avatar: '👨'},
{id: '3', name: 'Charlie', avatar: '🧑'},
]
return (
<div className="user-overlay">
{users.map(user => (
<div
key={user.id}
onClick={() =>
select({
value: user.name,
meta: user.id, // Store user ID in metadata
})
}
className="user-item"
>
<span>{user.avatar}</span>
<span>{user.name}</span>
</div>
))}
</div>
)
}
// Usage with markup that includes metadata
;<MarkedInput
Overlay={UserOverlay}
options={[
{
markup: '@[__value__](__meta__)',
overlay: {trigger: '@'},
},
]}
/>

Add keyboard support:

import {useOverlay} from '@markput/react'
import {useState, useEffect} from 'react'
function KeyboardOverlay() {
const {select, close, ref} = useOverlay<HTMLDivElement>()
const [selected, setSelected] = useState(0)
const items = ['Alice', 'Bob', 'Charlie']
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelected(prev => (prev + 1) % items.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelected(prev => (prev - 1 + items.length) % items.length)
} else if (e.key === 'Enter') {
e.preventDefault()
select({value: items[selected]})
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selected, items, select, close])
return (
<div ref={ref} className="overlay">
{items.map((item, index) => (
<div key={item} onClick={() => select({value: item})} className={index === selected ? 'selected' : ''}>
{item}
</div>
))}
</div>
)
}

Use the ref to detect clicks outside the overlay:

function ClickOutsideOverlay() {
const {select, ref} = useOverlay<HTMLDivElement>()
const items = ['Item 1', 'Item 2']
return (
<div
ref={ref} // Important for outside click detection
className="overlay"
>
{items.map(item => (
<div key={item} onClick={() => select({value: item})}>
{item}
</div>
))}
</div>
)
}

How it works:

  • Markput tracks clicks
  • If click is outside elements with ref, overlay closes
  • Always attach ref to your root overlay element
const options: Option[] = [
{
markup: '@[__value__]',
overlay: {trigger: '@', data: ['Alice', 'Bob']},
},
]

Different triggers for different mark types:

const options: Option[] = [
{markup: '@[__value__](user)', overlay: {trigger: '@', data: users}},
{markup: '#[__value__](hashtag)', overlay: {trigger: '#', data: hashtags}},
{markup: '/[__value__](command)', overlay: {trigger: '/', data: commands}},
]
const options: Option[] = [
{
markup: '{{__value__}}',
overlay: {trigger: '{{', data: ['name', 'email', 'date']},
},
]

Use different overlay components for different triggers:

import {MarkedInput} from '@markput/react'
function UserOverlay() {
const {select} = useOverlay()
return (
<div className="user-overlay">
<div onClick={() => select({value: 'Alice'})}>👩 Alice</div>
<div onClick={() => select({value: 'Bob'})}>👨 Bob</div>
</div>
)
}
function CommandOverlay() {
const {select} = useOverlay()
return (
<div className="command-overlay">
<div onClick={() => select({value: 'heading'})}>📝 Heading</div>
<div onClick={() => select({value: 'bold'})}>🔤 Bold</div>
</div>
)
}
function Editor() {
const [value, setValue] = useState('')
return (
<MarkedInput
value={value}
onChange={setValue}
Mark={props => <span>{props.value}</span>}
options={[
{
markup: '@[__value__]',
overlay: {slot: UserOverlay, trigger: '@'}, // Custom overlay for @
},
{
markup: '/[__value__]',
overlay: {slot: CommandOverlay, trigger: '/'}, // Custom overlay for /
},
]}
/>
)
}

Load data asynchronously:

import {useOverlay} from '@markput/react'
import {useState, useEffect} from 'react'
function AsyncOverlay() {
const {select, match} = useOverlay()
const [users, setUsers] = useState<{id: string; name: string}[]>([])
const [loading, setLoading] = useState(true)
// `match` is undefined while no trigger is open
const query = match?.value ?? ''
useEffect(() => {
setLoading(true)
// Fetch users based on typed text
fetch(`/api/users?q=${query}`)
.then(res => res.json())
.then(data => {
setUsers(data)
setLoading(false)
})
}, [query])
if (loading) {
return <div className="overlay">Loading...</div>
}
if (users.length === 0) {
return <div className="overlay">No users found</div>
}
return (
<div className="overlay">
{users.map(user => (
<div key={user.id} onClick={() => select({value: user.name, meta: user.id})}>
{user.name}
</div>
))}
</div>
)
}

Use showOverlayOn prop:

<MarkedInput
value={value}
onChange={setValue}
Mark={Mark}
showOverlayOn="change" // Default: show on text change
// or
showOverlayOn="selectionChange" // Show on cursor move
// or
showOverlayOn={['change', 'selectionChange']} // Both
// or
showOverlayOn="none" // Never show automatically
/>

Options:

  • "change" - Show when text changes (default)
  • "selectionChange" - Show when cursor moves
  • ["change", "selectionChange"] - Both events
  • "none" - Manual control only
import {useOverlay} from '@markput/react'
import {useState, useEffect} from 'react'
function RichUserOverlay() {
const {select, match, style, ref} = useOverlay<HTMLDivElement>()
const [selected, setSelected] = useState(0)
const users = [
{id: '1', name: 'Alice Johnson', avatar: '👩', role: 'Designer'},
{id: '2', name: 'Bob Smith', avatar: '👨', role: 'Developer'},
{id: '3', name: 'Charlie Brown', avatar: '🧑', role: 'Manager'},
]
// `match` is undefined while no trigger is open
const query = match?.value ?? ''
const filtered = users.filter(u => u.name.toLowerCase().includes(query.toLowerCase()))
useEffect(() => {
setSelected(0)
}, [query])
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelected(prev => (prev + 1) % filtered.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelected(prev => (prev - 1 + filtered.length) % filtered.length)
} else if (e.key === 'Enter' && filtered[selected]) {
e.preventDefault()
select({
value: filtered[selected].name,
meta: filtered[selected].id,
})
}
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [selected, filtered, select])
return (
<div
ref={ref}
style={{
position: 'absolute',
left: style.left,
top: style.top,
background: 'white',
border: '1px solid #e0e0e0',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
minWidth: '250px',
maxHeight: '300px',
overflow: 'auto',
zIndex: 1000,
}}
>
{filtered.length === 0 ? (
<div style={{padding: '16px', color: '#999'}}>No users found</div>
) : (
filtered.map((user, index) => (
<div
key={user.id}
onClick={() => select({value: user.name, meta: user.id})}
style={{
padding: '12px 16px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '12px',
background: index === selected ? '#f5f5f5' : 'transparent',
}}
>
<span style={{fontSize: '24px'}}>{user.avatar}</span>
<div>
<div style={{fontWeight: 500}}>{user.name}</div>
<div style={{fontSize: '12px', color: '#666'}}>{user.role}</div>
</div>
</div>
))
)}
</div>
)
}

This one is no longer an example of a custom overlay, because it is not custom any more — see The Row Menu. The list, the filtering and the write all moved into core:

const options = [
{overlay: {trigger: '/'}},
{markup: '# __slot__', row: {Component: 'h1'}, menu: {label: 'Heading 1', keywords: ['h1']}},
{markup: '## __slot__', row: {Component: 'h2'}, menu: {label: 'Heading 2', keywords: ['h2']}},
{markup: '- __slot__', row: {Component: 'li', continues: true}, menu: {label: 'Bulleted list'}},
{markup: '```__meta__\n__value__```', row: {Component: 'pre'}, menu: {label: 'Code', keywords: ['fence']}},
]
// Attach ref for outside click detection
<div ref={ref}>overlay content</div>
// Position overlay at caret
<div style={{ position: 'absolute', left: style.left, top: style.top }}>
// Filter based on match.value
const filtered = items.filter(item =>
item.toLowerCase().includes(match.value.toLowerCase())
)
// Handle empty results
{filtered.length === 0 && <div>No results</div>}
// Add keyboard navigation
useEffect(() => {
const handleKey = (e) => { /* handle arrow keys */ }
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [])
// Don't forget ref
<div>overlay</div> // Won't close on outside click
// Don't use fixed positioning without coordinates
<div style={{ position: 'fixed', top: 0, left: 0 }}> // Bad UX
// Don't forget to handle empty states
{items.map(item => ...)} // What if items is empty?
// Don't create memory leaks
useEffect(() => {
window.addEventListener('keydown', handler)
// Missing cleanup!
}, [])

Type your custom overlays:

import {useOverlay} from '@markput/react'
import type {OverlayHandler} from '@markput/react'
function TypedOverlay() {
const overlay: OverlayHandler<HTMLDivElement> = useOverlay<HTMLDivElement>()
const handleSelect = (value: string) => {
overlay.select({value, meta: 'optional'})
}
return <div ref={overlay.ref}>{/* overlay content */}</div>
}

Key Takeaways:

  • Use useOverlay() hook for custom overlays
  • Position with style.left and style.top
  • Attach ref for outside click detection
  • Use select() to insert marks
  • Add keyboard navigation for better UX

Try it live: CodeSandbox - Custom Overlay