Skip to content

🚧 Slots Customization

Markput uses the slots pattern (popularized by Material-UI) to give you fine-grained control over internal components. This guide covers how to customize the container and text rendering without losing built-in functionality.

Slots are a component customization pattern that separates structure from styling and behavior. Instead of wrapping or forking components, you customize them through props:

<MarkedInput
slots={{
container: CustomContainer, // Replace component
paragraph: CustomParagraph, // Replace component
}}
slotProps={{
container: {className: 'my-editor'}, // Pass props to the container
row: {className: 'my-row'}, // Pass props to every row
}}
/>

Key Concepts:

  • slots - Replace the default component entirely
  • slotProps - Pass props to the default (or custom) component
Slot Default Component Purpose
container <div> Root editable container — the one contenteditable
paragraph <div> The component a row with NO kind renders through
slotProps key Reaches
container The container element
row Every row’s wrapper

slots and slotProps do not take the same keys, and the names say why: slots.paragraph is consulted only for a row with NO kind, while slotProps.row is merged onto EVERY row’s wrapper — kind and paragraph alike.

What’s NOT a slot:

  • Mark components (use the Mark prop, or option.Mark)
  • Overlay components (use the Overlay prop, or option.Overlay)
  • Plain text segments (use the Span prop)
  • A row kind’s component (use option.row.Component — see Row Kinds)

Both key sets are declared on the published types, in both adapters: Slots and SlotProps each extend the core contract, so a key core resolves is a key TypeScript accepts. A slot value may be a component or an intrinsic tag name — slots={{container: 'article'}} mounts the editor on an <article>.

The simplest way to customize slots is through slotProps. This passes props to the default components without replacing them.

import {MarkedInput} from '@markput/react'
function StyledEditor() {
const [value, setValue] = useState('')
return (
<MarkedInput
value={value}
onChange={setValue}
Mark={MyMark}
slotProps={{
container: {
style: {
border: '2px solid #e0e0e0',
borderRadius: '8px',
padding: '12px',
minHeight: '120px',
fontSize: '16px',
lineHeight: '1.6',
},
},
row: {
style: {
whiteSpace: 'pre-wrap', // Preserve whitespace
},
},
}}
/>
)
}
<MarkedInput
Mark={MyMark}
slotProps={{
container: {
className: 'editor-container',
},
row: {
className: 'editor-row',
},
}}
/>
styles.css
.editor-container {
border: 1px solid #ddd;
border-radius: 4px;
padding: 16px;
font-family: 'Inter', sans-serif;
}
.editor-container:focus {
outline: 2px solid #2196f3;
border-color: transparent;
}
.editor-row {
color: #333;
letter-spacing: 0.01em;
}

Add event handlers through slotProps:

function EditorWithEvents() {
const [value, setValue] = useState('')
const [isFocused, setIsFocused] = useState(false)
return (
<MarkedInput
value={value}
onChange={setValue}
Mark={MyMark}
slotProps={{
container: {
onFocus: (e: React.FocusEvent<HTMLDivElement>) => {
console.log('Editor focused')
setIsFocused(true)
},
onBlur: (e: React.FocusEvent<HTMLDivElement>) => {
console.log('Editor blurred')
setIsFocused(false)
},
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
console.log('Save triggered')
}
},
onPaste: (e: React.ClipboardEvent<HTMLDivElement>) => {
console.log('Pasted:', e.clipboardData.getData('text'))
},
style: {
outline: isFocused ? '2px solid blue' : 'none',
},
},
}}
/>
)
}

Improve accessibility with ARIA attributes:

<MarkedInput
Mark={MyMark}
slotProps={{
container: {
role: 'textbox',
'aria-label': 'Message input',
'aria-multiline': true,
'aria-required': true,
'aria-describedby': 'editor-help-text'
}
}}
/>
<p id="editor-help-text" className="help-text">
Type @ to mention someone
</p>

Add custom data attributes for testing or analytics:

<MarkedInput
Mark={MyMark}
slotProps={{
container: {
'data-testid': 'editor-input',
'data-editor-type': 'mention-editor',
'data-track': 'user-input',
},
row: {
'data-doc-row': true,
},
}}
/>

For deeper customization, replace the default components entirely with slots.

Replace the container with a custom component:

import {forwardRef} from 'react'
import type {HTMLAttributes} from 'react'
const CustomContainer = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>((props, ref) => {
return (
<div
{...props}
ref={ref}
style={{
...props.style,
border: '2px dashed #9c27b0',
borderRadius: '12px',
padding: '16px',
backgroundColor: '#f5f5f5',
}}
/>
)
})
function Editor() {
return (
<MarkedInput
Mark={MyMark}
slots={{
container: CustomContainer,
}}
/>
)
}

Important: Custom slot components MUST:

  1. Accept all props with spread ({...props})
  2. Forward the ref (forwardRef)
  3. Be typed correctly for TypeScript

slots.paragraph is the component a row with NO kind renders through — the default is a bare div. Spread everything you are given onto the element you render, the row’s rendered content included:

const Paragraph = ({children, ref, className, style}: RowProps) => (
<p ref={ref} className={className} style={style}>
{children}
</p>
)
const Editor = () => (
<MarkedInput Mark={MyMark} slots={{paragraph: Paragraph}} />
)

A row that MATCHED a kind never reaches this slot; it renders through option.row.Component. See Row Kinds.

Plain text is not a slot — it is the Span prop, and it is a special case: the element it renders IS the surface core writes the token’s text into, so it cannot be wrapped and it must not be given a second writer.

// `SpanProps` is the text token's props AND the ref core writes text through
const Mono = ({value, ref}: SpanProps) => (
<span ref={ref} style={{fontFamily: 'monospace', letterSpacing: '0.5px'}}>
{value}
</span>
)
const Editor = () => (
<MarkedInput Mark={MyMark} Span={Mono} />
)

The ref must land on the element that shows the text. A component that drops it leaves the text unbound and the caret cannot resolve into it.

The prop carries that contract, so an inline Span needs no annotation at all — value and ref are typed from where it is written:

const Editor = () => <MarkedInput Mark={MyMark} Span={({value, ref}) => <span ref={ref}>{value}</span>} />

Use both together - slots to replace components, slotProps to pass additional props:

const CustomContainer = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
(props, ref) => (
<div {...props} ref={ref} className={`custom-editor ${props.className || ''}`} />
)
)
const Editor = () => (
<MarkedInput
Mark={MyMark}
slots={{
container: CustomContainer // Custom component
}}
slotProps={{
container: {
className: 'with-shadow', // Props passed to CustomContainer
onFocus: () => console.log('Focused')
}
}}
/>
)

The props from slotProps.container will be passed to your CustomContainer component.

Good for dynamic styles based on state:

function ThemedEditor() {
const [theme, setTheme] = useState('light')
const containerStyle = {
backgroundColor: theme === 'light' ? '#fff' : '#1e1e1e',
color: theme === 'light' ? '#000' : '#fff',
border: `1px solid ${theme === 'light' ? '#ddd' : '#444'}`,
}
return (
<MarkedInput
Mark={MyMark}
slotProps={{
container: {style: containerStyle},
}}
/>
)
}

Good for static styles and media queries:

<MarkedInput
Mark={MyMark}
slotProps={{
container: {className: 'editor-modern'},
}}
/>
.editor-modern {
border: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.editor-modern:focus {
box-shadow: 0 10px 60px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.editor-modern {
padding: 12px;
border-radius: 8px;
}
}

Good for component libraries and scoped styles:

import {styled} from '@mui/material/styles'
const StyledContainer = styled('div')(({theme}: any) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
'&:focus': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: 2,
},
}))
const StyledParagraph = styled('div')(({theme}: any) => ({
color: theme.palette.text.primary,
fontSize: theme.typography.body1.fontSize,
}))
function MuiEditor() {
return (
<MarkedInput
Mark={MyMark}
slots={{
container: StyledContainer,
paragraph: StyledParagraph,
}}
/>
)
}

Good for utility-first styling:

const TailwindContainer = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
(props, ref) => (
<div
{...props}
ref={ref}
className={`
border border-gray-300 rounded-lg p-4
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
bg-white dark:bg-gray-800 dark:border-gray-600
min-h-[120px]
${props.className || ''}
`}
/>
)
)
const Editor = () => (
<MarkedInput
Mark={MyMark}
slots={{
container: TailwindContainer
}}
/>
)

Show placeholder when editor is empty:

const ContainerWithPlaceholder = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement> & {isEmpty?: boolean}>(
({isEmpty, ...props}, ref) => (
<div {...props} ref={ref} style={{position: 'relative'}}>
{isEmpty && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
color: '#999',
padding: 'inherit',
}}
>
Type @ to mention someone...
</div>
)}
</div>
)
)
function EditorWithPlaceholder() {
const [value, setValue] = useState('')
return (
<MarkedInput
value={value}
onChange={setValue}
Mark={MyMark}
slots={{
container: ContainerWithPlaceholder,
}}
slotProps={{
container: {
isEmpty: value.trim() === '',
},
}}
/>
)
}

Add a character count overlay:

const ContainerWithCounter = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & {charCount?: number; maxChars?: number}
>(({charCount = 0, maxChars = 500, ...props}, ref) => (
<div style={{position: 'relative'}}>
<div {...props} ref={ref} />
<div
style={{
position: 'absolute',
bottom: 8,
right: 8,
fontSize: '12px',
color: charCount > maxChars ? '#f44336' : '#999',
pointerEvents: 'none',
}}
>
{charCount} / {maxChars}
</div>
</div>
))
function EditorWithCounter() {
const [value, setValue] = useState('')
return (
<MarkedInput
value={value}
onChange={setValue}
Mark={MyMark}
slots={{
container: ContainerWithCounter,
}}
slotProps={{
container: {
charCount: value.length,
maxChars: 500,
},
}}
/>
)
}

Highlight container on focus:

const FocusableContainer = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
(props, ref) => {
const [focused, setFocused] = useState(false)
return (
<div
{...props}
ref={ref}
onFocus={(e) => {
setFocused(true)
props.onFocus?.(e)
}}
onBlur={(e) => {
setFocused(false)
props.onBlur?.(e)
}}
style={{
...props.style,
border: focused ? '2px solid #2196f3' : '1px solid #ddd',
boxShadow: focused ? '0 0 0 3px rgba(33, 150, 243, 0.1)' : 'none',
transition: 'all 0.2s ease'
}}
/>
)
}
)
const Editor = () => (
<MarkedInput
Mark={MyMark}
slots={{
container: FocusableContainer
}}
/>
)

Add line numbers for multi-line content:

const ContainerWithLineNumbers = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement> & {lineCount?: number}>(
({lineCount = 1, ...props}, ref) => (
<div style={{display: 'flex'}}>
<div
style={{
width: '40px',
backgroundColor: '#f5f5f5',
padding: '8px',
textAlign: 'right',
color: '#999',
fontSize: '12px',
userSelect: 'none',
borderRight: '1px solid #ddd',
}}
>
{Array.from({length: lineCount}, (_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
<div {...props} ref={ref} style={{flex: 1, ...props.style}} />
</div>
)
)
function EditorWithLineNumbers() {
const [value, setValue] = useState('')
const lineCount = value.split('\n').length
return (
<MarkedInput
value={value}
onChange={setValue}
Mark={MyMark}
slots={{
container: ContainerWithLineNumbers,
}}
slotProps={{
container: {
lineCount,
},
}}
/>
)
}

Custom text rendering with highlighting, through the Span prop:

import type {SpanProps} from '@markput/react'
const Highlighted = ({value = '', ref}: SpanProps) => {
const isUrl = /^https?:\/\//.test(value)
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
const style = isUrl
? {color: '#2196f3', textDecoration: 'underline'}
: isEmail
? {color: '#4caf50'}
: undefined
return (
<span ref={ref} style={style}>
{value}
</span>
)
}
const Editor = () => (
<MarkedInput Mark={MyMark} Span={Highlighted} />
)

The styling reacts to the token’s text, but the text on screen is core’s to write: keep the ref on the element and put nothing beside the value inside it.

import {Paper, useTheme} from '@mui/material'
import {styled} from '@mui/material/styles'
const MuiContainer = styled(Paper)(({theme}: any) => ({
padding: theme.spacing(2),
minHeight: 120,
border: `1px solid ${theme.palette.divider}`,
'&:focus-within': {
borderColor: theme.palette.primary.main,
boxShadow: `0 0 0 2px ${theme.palette.primary.main}25`,
},
}))
function MuiEditor() {
return (
<MarkedInput
Mark={MyMark}
slots={{
container: MuiContainer,
}}
slotProps={{
container: {
elevation: 0,
},
}}
/>
)
}
import { Box } from '@chakra-ui/react'
import { forwardRef } from 'react'
const ChakraContainer = forwardRef((props, ref) => (
<Box
{...props}
ref={ref}
borderWidth="1px"
borderRadius="md"
p={4}
minH="120px"
_focus={{
borderColor: 'blue.500',
boxShadow: 'outline'
}}
/>
))
const Editor = () => (
<MarkedInput
Mark={MyMark}
slots={{
container: ChakraContainer
}}
/>
)
import { Input } from 'antd'
import { forwardRef } from 'react'
const AntContainer = forwardRef<HTMLDivElement, any>((props, ref) => (
<div
{...props}
ref={ref}
className="ant-input"
style={{
minHeight: 120,
...props.style
}}
/>
))
const Editor = () => (
<MarkedInput
Mark={MyMark}
slots={{
container: AntContainer
}}
/>
)
import {forwardRef} from 'react'
import type {HTMLAttributes, CSSProperties} from 'react'
// Type container with custom props
interface CustomContainerProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'outlined' | 'filled'
error?: boolean
}
const TypedContainer = forwardRef<HTMLDivElement, CustomContainerProps>(
({variant = 'outlined', error = false, ...props}, ref) => {
const style: CSSProperties = {
...props.style,
border: error ? '2px solid red' : '1px solid #ddd',
backgroundColor: variant === 'filled' ? '#f5f5f5' : 'transparent',
}
return <div {...props} ref={ref} style={style} />
}
)
// Usage with type safety
function TypedEditor() {
return (
<MarkedInput
Mark={MyMark}
slots={{
container: TypedContainer,
}}
slotProps={{
container: {
variant: 'filled',
error: true,
},
}}
/>
)
}
import type {MarkedInputProps} from '@markput/react'
interface EditorProps {
containerClass?: string
}
function ConfigurableEditor({containerClass}: EditorProps) {
const slotProps: MarkedInputProps['slotProps'] = {
container: {
className: containerClass,
},
}
return <MarkedInput Mark={MyMark} slotProps={slotProps} />
}

Prevent unnecessary re-renders:

import { memo, forwardRef } from 'react'
const MemoizedContainer = memo(
forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
(props, ref) => (
<div {...props} ref={ref} className="editor-container" />
)
)
)
const Editor = () => (
<MarkedInput
Mark={MyMark}
slots={{
container: MemoizedContainer
}}
/>
)
// ❌ Bad - creates new function each render
;<MarkedInput
slotProps={{
container: {
onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => console.log(e.key),
},
}}
/>
// ✅ Good - stable function reference
function Editor() {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
console.log(e.key)
}, [])
return (
<MarkedInput
slotProps={{
container: {
onKeyDown: handleKeyDown,
},
}}
/>
)
}
function Editor() {
const slotProps = useMemo(
() => ({
container: {
className: 'editor',
style: {padding: '16px'},
},
}),
[]
) // Only created once
return <MarkedInput Mark={MyMark} slotProps={slotProps} />
}
import {MarkedInput} from '@markput/react'
import {useState, forwardRef} from 'react'
import type {HTMLAttributes} from 'react'
const GitHubContainer = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>((props, ref) => (
<div
{...props}
ref={ref}
style={{
...props.style,
border: '1px solid #d0d7de',
borderRadius: '6px',
padding: '8px 12px',
fontSize: '14px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
minHeight: '100px',
backgroundColor: '#fff',
}}
/>
))
function GitHubEditor() {
const [value, setValue] = useState('')
return (
<div style={{maxWidth: '800px'}}>
<div
style={{
border: '1px solid #d0d7de',
borderRadius: '6px',
overflow: 'hidden',
}}
>
<div
style={{
backgroundColor: '#f6f8fa',
padding: '8px 12px',
borderBottom: '1px solid #d0d7de',
fontSize: '14px',
color: '#57606a',
}}
>
Write a comment
</div>
<MarkedInput
value={value}
onChange={setValue}
Mark={({value}) => <span style={{color: '#0969da', fontWeight: 600}}>@{value}</span>}
slots={{
container: GitHubContainer,
}}
slotProps={{
container: {
'aria-label': 'Comment body',
},
}}
options={[
{
markup: '@[__value__]',
overlay: {trigger: '@', data: ['octocat', 'github', 'copilot']},
},
]}
/>
</div>
</div>
)
}
const NotionContainer = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>((props, ref) => {
const [placeholder, setPlaceholder] = useState("Type '/' for commands")
return (
<div
{...props}
ref={ref}
style={{
...props.style,
fontSize: '16px',
lineHeight: '1.6',
fontFamily:
'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
padding: '12px 96px',
minHeight: '300px',
outline: 'none',
}}
data-placeholder={placeholder}
onFocus={() => setPlaceholder('')}
onBlur={e => {
if (!e.currentTarget.textContent) setPlaceholder("Type '/' for commands")
props.onBlur?.(e)
}}
/>
)
})
function NotionEditor() {
const [value, setValue] = useState('')
return (
<MarkedInput
value={value}
onChange={setValue}
Mark={({value}) => (
<span
style={{
backgroundColor: '#f1f1ef',
padding: '2px 6px',
borderRadius: '3px',
fontSize: '85%',
fontFamily: 'monospace',
}}
>
{value}
</span>
)}
slots={{container: NotionContainer}}
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'}},
]}
draggable
/>
)
}

The container must render whatever it is given: the editor’s own children ARE the document. Paint a placeholder with CSS on [data-placeholder]:empty::before rather than by replacing them. What makes this a Notion-shaped editor is the row vocabulary beside it — see Row Kinds.

type ContainerProps = HTMLAttributes<HTMLDivElement>
// Always forward refs
const ForwardingContainer = forwardRef<HTMLDivElement, ContainerProps>((props, ref) => <div {...props} ref={ref} />)
// Spread all props
const SpreadingContainer = forwardRef<HTMLDivElement, ContainerProps>((props, ref) => (
<div {...props} ref={ref} className={`custom ${props.className || ''}`} />
))
// Preserve existing style
const StyleKeepingContainer = forwardRef<HTMLDivElement, ContainerProps>((props, ref) => (
<div {...props} ref={ref} style={{...props.style, padding: '16px'}} />
))
// Memoize stable components
const StableContainer = memo(
forwardRef<HTMLDivElement, ContainerProps>((props, ref) => <div {...props} ref={ref} />)
)
// Don't forget forwardRef
const Bad = (props) => <div {...props} /> // Missing ref!
// Don't forget to spread props
const Bad = forwardRef((props, ref) => (
<div ref={ref} className="custom" /> // Lost all props!
))
// Don't override style completely
const Bad = forwardRef((props, ref) => (
<div {...props} ref={ref} style={{ padding: '16px' }} /> // Lost original style!
))
// Don't use inline components
const Editor = () => (
<MarkedInput
slots={{
container: (props) => <div {...props} /> // Creates new component each render!
}}
/>
// Don't forget TypeScript types
const Bad = forwardRef((props, ref) => ( // Any types!
<div {...props} ref={ref} />
))
)