🚧 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.
Overview
Section titled “Overview”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.
The Default Overlay List
Section titled “The Default Overlay List”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
Suggestions with an identity
Section titled “Suggestions with an identity”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.
The Row Menu
Section titled “The Row Menu”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> )}Where a Popup Opens
Section titled “Where a Popup Opens”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.
Theming the Built-in Look
Section titled “Theming the Built-in Look”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%);}The useOverlay Hook
Section titled “The useOverlay Hook”Build custom overlays with the useOverlay() hook:
import {useOverlay} from '@markput/react'
function CustomOverlay() { const overlay = useOverlay()
return <div>Custom overlay</div>}useOverlay API
Section titled “useOverlay API”| 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>}Custom Overlay Examples
Section titled “Custom Overlay Examples”Example 1: Simple List
Section titled “Example 1: Simple List”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: '@'}}]} />Example 2: Positioned Overlay
Section titled “Example 2: Positioned Overlay”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> )}Example 3: Filtered Suggestions
Section titled “Example 3: Filtered Suggestions”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> )}Example 4: With Metadata
Section titled “Example 4: With Metadata”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: '@'}, }, ]}/>Example 5: Keyboard Navigation
Section titled “Example 5: Keyboard Navigation”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> )}Outside Click Detection
Section titled “Outside Click Detection”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
refto your root overlay element
Trigger Configuration
Section titled “Trigger Configuration”Single Trigger
Section titled “Single Trigger”const options: Option[] = [ { markup: '@[__value__]', overlay: {trigger: '@', data: ['Alice', 'Bob']}, },]Multiple Triggers
Section titled “Multiple Triggers”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}},]Multi-Character Triggers
Section titled “Multi-Character Triggers”const options: Option[] = [ { markup: '{{__value__}}', overlay: {trigger: '{{', data: ['name', 'email', 'date']}, },]Per-Option Custom Overlays
Section titled “Per-Option Custom Overlays”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 / }, ]} /> )}Overlay with Data Loading
Section titled “Overlay with Data Loading”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> )}Controlling Overlay Visibility
Section titled “Controlling Overlay Visibility”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
Complete Examples
Section titled “Complete Examples”Example: Rich User Selector
Section titled “Example: Rich User Selector”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> )}Example: Notion-style Slash Commands
Section titled “Example: Notion-style Slash Commands”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']}},]Best Practices
Section titled “Best Practices”// 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.valueconst filtered = items.filter(item => item.toLowerCase().includes(match.value.toLowerCase()))
// Handle empty results{filtered.length === 0 && <div>No results</div>}
// Add keyboard navigationuseEffect(() => { const handleKey = (e) => { /* handle arrow keys */ } window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey)}, [])❌ Don’t
Section titled “❌ Don’t”// 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 leaksuseEffect(() => { window.addEventListener('keydown', handler) // Missing cleanup!}, [])TypeScript Support
Section titled “TypeScript Support”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.leftandstyle.top - Attach
reffor outside click detection - Use
select()to insert marks - Add keyboard navigation for better UX
Try it live: CodeSandbox - Custom Overlay