Skip to content

Getting Started

Build interactive text with custom markup - quick start in minutes.

Install Markput using your preferred package manager:

Terminal window
# React
npm install @markput/react
# Vue
npm install @markput/vue

Requirements: React 19+ for @markput/react, Vue 3.5+ for @markput/vue.

The tutorial below uses React; the Vue API mirrors it — see the Vue Storybook for live Vue examples.

Let’s build a marked text editor with autocomplete in three steps.

Here’s a basic editor rendering marked text. Try clicking the highlighted text:

import {MarkedInput} from '@markput/react'
export const Step1Demo = () => (
<MarkedInput
Mark={({value, meta}) => <mark onClick={() => alert(meta)}>{value}</mark>}
defaultValue="Hello @[World](123)!"
/>
)

How it works:

Markput uses a special markup syntax to represent interactive elements as plain text:

  • Markup — a text pattern that encodes structured data: @[__value__](__meta__)
  • Value — the visible text shown to users (e.g., World)
  • Meta — hidden metadata for your app (e.g., 123 - user ID)
  • Mark — a React component that renders the markup visually

When Markput encounters @[World](123) in the text:

  1. Parses the markup and extracts: value="World", meta="123"
  2. Renders your Mark component with these props: <mark onClick={...}>{props.value}</mark>
  3. Preserves the original text as a simple string — easy to save or send to any backend

Since Mark is a regular React component, you can style it, add click handlers (like the onClick that shows an alert), or use any React features.

Add the options prop to enable autocomplete suggestions:

import {MarkedInput} from '@markput/react'
export const Step2Demo = () => (
<MarkedInput
Mark={({value}) => <mark>@{value}</mark>}
defaultValue="Type @ to mention someone!"
options={[
{
markup: '@[__value__]',
overlay: {
trigger: '@',
data: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
},
},
]}
/>
)

How it works:

The options prop configures autocomplete behavior:

  • markup — the pattern the choice is written as. A markup must not begin with a placeholder, so there is always a literal opener; @[__value__] turns Alice into @[Alice]
  • overlay — configuration for the built-in Suggestions component:
    • trigger — character that opens the overlay (here: @)
    • data — array of suggestions to display
  • Mark — the component that renders what was inserted

When you type the trigger character @:

  1. Markput detects the trigger and shows the built-in Suggestions component
  2. As you type, suggestions are filtered based on your input
  3. When you select an item (e.g., 'Alice'), Markput writes @[Alice] into the value and renders it through Mark

Keyboard navigation is built-in (↑↓ to navigate, Enter to select, Esc to close).

The built-in Suggestions component is convenient, but sometimes you need full control over the UI. The Overlay prop lets you render a completely custom component, and the useOverlay hook provides all the state and actions you need.

Here’s a custom mention UI that fetches GitHub users and displays avatars:

import {MarkedInput, useOverlay} from '@markput/react'
import {type RefObject, useEffect, useState} from 'react'
const userStyle: React.CSSProperties = {display: 'inline-flex', gap: '0.5rem'}
const userClickableStyle: React.CSSProperties = {display: 'flex', padding: '0.5rem', cursor: 'pointer'}
const avatarStyle: React.CSSProperties = {height: '1.5rem', width: '1.5rem', borderRadius: '9999px'}
const overlayStyle: React.CSSProperties = {
position: 'fixed',
zIndex: 10,
border: '1px solid #e5e7eb',
background: 'white',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
}
const User = ({avatar, login, onClick}: {avatar?: string; login?: string; onClick?: () => void}) => (
<span style={onClick ? userClickableStyle : userStyle} onClick={onClick}>
<img src={avatar} alt="" style={avatarStyle} />
{login}
</span>
)
const CustomOverlay = () => {
const {select, match, style, ref} = useOverlay()
const [users, setUsers] = useState<{login: string; avatar_url: string}[]>([])
useEffect(() => {
if (!match?.value) return
fetch(`https://api.github.com/search/users?q=${match.value}`)
.then(res => res.json())
.then(data => setUsers(data.items?.slice(0, 10) || []))
}, [match?.value])
return (
<div ref={ref as RefObject<HTMLDivElement>} style={{...overlayStyle, top: style.top, left: style.left}}>
{users?.map(user => (
<User
key={user.login}
avatar={user.avatar_url}
login={user.login}
onClick={() => select({value: user.login, meta: user.avatar_url})}
/>
))}
</div>
)
}
export const Step3Demo = () => (
<MarkedInput
defaultValue="Type @ to mention someone!"
Mark={({value, meta}) => <User avatar={meta} login={value} />}
Overlay={CustomOverlay}
/>
)

How it works:

The useOverlay hook returns an object with everything you need to build a custom overlay:

  • match — current search state with match.value containing what the user typed after the trigger
  • select — function to insert markup: select({value: string, meta?: string})
  • close — function to dismiss the overlay without selecting
  • style — object with top and left coordinates for positioning near the cursor
  • ref — React ref to attach to your overlay element for proper event handling

The hook handles all the complexity: detecting triggers, tracking the search query, and positioning the overlay. When you call select({value, meta}), it inserts the markup and closes the overlay.

In this example, we fetch GitHub users and store the username as value and avatar URL as meta. The Mark component then uses meta to display the avatar. You can use any UI library or custom component. Markput doesn’t impose styling constraints.

The editor above is already a multi-row document: separator defaults to '\n', so every line is a row with its own drag grip and its own place in the tree. Enter opens a row, Tab nests one under another, Esc selects one, and Ctrl/Cmd+Z undoes — the editor keeps its own history stack.

An option that declares row turns its markup into a row kind, matched only at a row’s own start:

import {MarkedInput} from '@markput/react'
<MarkedInput
defaultValue={'# Launch plan\n- ship it'}
draggable
options={[
{overlay: {trigger: '/'}},
{markup: '# __slot__', row: {Component: Heading}, menu: {label: 'Heading 1'}},
{markup: '- __slot__', row: {Component: Bullet, continues: true, indents: true}, menu: {label: 'Bulleted list'}},
]}
/>

That is the whole wiring for a / menu too: an option that declares a menu IS in it. Read Rows and Nesting next, then Row Kinds.

Pass separator={null} if you want the old behaviour — one document, no rows, no row controls.

Explore these interactive examples on CodeSandbox: