docs/rich-text/official-features.mdx
Below are all the Rich Text Features Payload offers. Everything is customizable; you can create your own features, modify ours and share them with the community.
| Feature Name | Included by default | Description |
|---|---|---|
BoldFeature | Yes | Adds support for bold text formatting. |
ItalicFeature | Yes | Adds support for italic text formatting. |
UnderlineFeature | Yes | Adds support for underlined text formatting. |
StrikethroughFeature | Yes | Adds support for strikethrough text formatting. |
SubscriptFeature | Yes | Adds support for subscript text formatting. |
SuperscriptFeature | Yes | Adds support for superscript text formatting. |
InlineCodeFeature | Yes | Adds support for inline code formatting. |
ParagraphFeature | Yes | Provides entries in both the slash menu and toolbar dropdown for explicit paragraph creation or conversion. |
HeadingFeature | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
AlignFeature | Yes | Adds support for text alignment (left, center, right, justify) |
IndentFeature | Yes | Adds support for text indentation with toolbar buttons |
UnorderedListFeature | Yes | Adds support for unordered lists (ul) |
OrderedListFeature | Yes | Adds support for ordered lists (ol) |
ChecklistFeature | Yes | Adds support for interactive checklists |
LinkFeature | Yes | Allows you to create internal and external links |
RelationshipFeature | Yes | Allows you to create block-level (not inline) relationships to other documents |
BlockquoteFeature | Yes | Allows you to create block-level quotes |
UploadFeature | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
HorizontalRuleFeature | Yes | Adds support for horizontal rules / separators. Basically displays an <hr> element |
InlineToolbarFeature | Yes | Provides a floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text |
FixedToolbarFeature | No | Provides a persistent toolbar pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
BlocksFeature | No | Allows you to use Payload's Blocks Field directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
TreeViewFeature | No | Provides a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
EXPERIMENTAL_TableFeature | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. |
TextStateFeature | No | Allows you to store key-value attributes within TextNodes and assign them inline styles. |
**bold** or __bold__*italic* or _italic_~~strikethrough~~#, ##, ###, ..., at start of line.type HeadingFeatureProps = {
enabledHeadingSizes?: HeadingTagType[] // ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
}
HeadingFeature({
enabledHeadingSizes: ['h1', 'h2', 'h3'], // Default: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
})
type IndentFeatureProps = {
/**
* The nodes that should not be indented. "type"
* property of the nodes you don't want to be indented.
* These can be: "paragraph", "heading", "listitem",
* "quote" or other indentable nodes if they exist.
*/
disabledNodes?: string[]
/**
* If true, pressing Tab in the middle of a block such
* as a paragraph or heading will not insert a tabNode.
* Instead, Tab will only be used for block-level indentation.
* @default false
*/
disableTabNode?: boolean
}
// Allow block-level indentation only
IndentFeature({
disableTabNode: true,
})
-, *, or + at start of line1. at start of line- [ ] (unchecked) or - [x] (checked)[anchor](url)type LinkFeatureServerProps = {
/**
* Disables the automatic creation of links
* from URLs typed or pasted into the editor,
* @default false
*/
disableAutoLinks?: 'creationOnly' | true
/**
* A function or array defining additional
* fields for the link feature.
* These will be displayed in the link editor drawer.
*/
fields?:
| ((args: {
config: SanitizedConfig
defaultFields: FieldAffectingData[]
}) => (Field | FieldAffectingData)[])
| Field[]
/**
* Sets a maximum population depth for the internal
* doc default field of link, regardless of the
* remaining depth when the field is reached.
*/
maxDepth?: number
} & ExclusiveLinkCollectionsProps
type ExclusiveLinkCollectionsProps =
| {
disabledCollections?: CollectionSlug[]
enabledCollections?: never
}
| {
disabledCollections?: never
enabledCollections?: CollectionSlug[]
}
LinkFeature({
fields: ({ defaultFields }) => [
...defaultFields,
{
name: 'rel',
type: 'select',
options: ['noopener', 'noreferrer', 'nofollow'],
},
],
enabledCollections: ['pages', 'posts'], // Collections for internal links
maxDepth: 2, // Population depth for internal links
disableAutoLinks: false, // Allow auto-conversion of URLs
})
type RelationshipFeatureProps = {
/**
* Sets a maximum population depth for this relationship,
* regardless of the remaining depth when the respective
* field is reached.
*/
maxDepth?: number
} & ExclusiveRelationshipFeatureProps
type ExclusiveRelationshipFeatureProps =
| {
disabledCollections?: CollectionSlug[]
enabledCollections?: never
}
| {
disabledCollections?: never
enabledCollections?: CollectionSlug[]
}
RelationshipFeature({
disabledCollections: ['users'], // Collections to exclude
maxDepth: 2, // Population depth for relationships
})
type UploadFeatureProps = {
collections?: {
[collection: UploadCollectionSlug]: {
fields: Field[]
}
}
/**
* Sets a maximum population depth for this upload (not the fields for this upload), regardless of the remaining depth when the respective field is reached.
* This behaves exactly like the maxDepth properties of relationship and upload fields.
*
* {@link https://payloadcms.com/docs/getting-started/concepts#field-level-max-depth}
*/
maxDepth?: number
} & ExclusiveUploadFeatureProps
type ExclusiveUploadFeatureProps =
| {
/**
* The collections that should be disabled. Overrides the `enableRichTextRelationship` property in the collection config.
* When this property is set, `enabledCollections` will not be available.
**/
disabledCollections?: UploadCollectionSlug[]
// Ensures that enabledCollections is not available when disabledCollections is set
enabledCollections?: never
}
| {
// Ensures that disabledCollections is not available when enabledCollections is set
disabledCollections?: never
/**
* The collections that should be enabled. Overrides the `enableRichTextRelationship` property in the collection config
* When this property is set, `disabledCollections` will not be available.
**/
enabledCollections?: UploadCollectionSlug[]
}
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'text',
label: 'Caption',
},
{
name: 'alt',
type: 'text',
label: 'Alt Text',
},
],
},
},
maxDepth: 1, // Population depth for uploads
disabledCollections: ['specialUploads'], // Collections to exclude
})
> quote text---type FixedToolbarFeatureProps = {
/**
* @default false
* If this is enabled, the toolbar will apply
* to the focused editor, not the editor with
* the FixedToolbarFeature.
*/
applyToFocusedEditor?: boolean
/**
* Custom configurations for toolbar groups
* Key is the group key (e.g. 'format', 'indent', 'align')
* Value is a partial ToolbarGroup object that will
* be merged with the default configuration
*/
customGroups?: CustomGroups
/**
* @default false
* If there is a parent editor with a fixed toolbar,
* this will disable the toolbar for this editor.
*/
disableIfParentHasFixedToolbar?: boolean
}
FixedToolbarFeature({
applyToFocusedEditor: false, // Apply to focused editor
customGroups: {
format: {
// Custom configuration for format group
},
},
})
For complete documentation including custom block components, the pre-built CodeBlock, and rendering blocks on the frontend, see the dedicated Blocks documentation.
type TextStateFeatureProps = {
/**
* The keys of the top-level object (stateKeys) represent the attributes that the textNode can have (e.g., color).
* The values of the top-level object (stateValues) represent the values that the attribute can have (e.g., red, blue, etc.).
* Within the stateValue, you can define inline styles and labels.
*/
state: { [stateKey: string]: StateValues }
}
type StateValues = {
[stateValue: string]: {
css: StyleObject
label: string
}
}
type StyleObject = {
[K in keyof PropertiesHyphenFallback]?:
| Extract<PropertiesHyphenFallback[K], string>
| undefined
}
// We offer default colors that have good contrast and look good in dark and light mode.
import { defaultColors, TextStateFeature } from '@payloadcms/richtext-lexical'
TextStateFeature({
// prettier-ignore
state: {
color: {
...defaultColors,
// fancy gradients!
galaxy: { label: 'Galaxy', css: { background: 'linear-gradient(to right, #0000ff, #ff0000)', color: 'white' } },
sunset: { label: 'Sunset', css: { background: 'linear-gradient(to top, #ff5f6d, #6a3093)' } },
},
// You can have both colored and underlined text at the same time.
// If you don't want that, you should group them within the same key.
// (just like I did with defaultColors and my fancy gradients)
underline: {
'solid': { label: 'Solid', css: { 'text-decoration': 'underline', 'text-underline-offset': '4px' } },
// You'll probably want to use the CSS light-dark() utility.
'yellow-dashed': { label: 'Yellow Dashed', css: { 'text-decoration': 'underline dashed', 'text-decoration-color': 'light-dark(#EAB308,yellow)', 'text-underline-offset': '4px' } },
},
},
}),
This is what the example above will look like:
<LightDarkImage srcDark="https://payloadcms.com/images/docs/text-state-feature.png" srcLight="https://payloadcms.com/images/docs/text-state-feature.png" alt="Example usage in light and dark mode for TextStateFeature with defaultColors and some custom styles" />
When Lexical serializes a text node that has state applied, the state is stored under a "$" key in the node object. To apply the styles when rendering rich text on your frontend, you need a custom text JSX converter that reads from this key and maps the values back to your CSS config.
Step 1 — Share your state config
Extract your TextStateFeature state into a file with no package imports so it can be safely imported in both server and client contexts:
// src/fields/textStateConfig.ts
export const textStateConfig = {
color: {
'text-red': {
label: 'Red',
css: {
color:
'light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216))',
},
},
'text-blue': {
label: 'Blue',
css: {
color:
'light-dark(oklch(0.546 0.245 262.881), oklch(0.707 0.165 254.624))',
},
},
// ...other colors
},
} as const
Then reference it in your field config:
// src/blocks/Content/config.ts
import { TextStateFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import { textStateConfig } from '@/fields/textStateConfig'
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => [
...rootFeatures,
TextStateFeature({
state: {
color: textStateConfig.color,
},
}),
],
}),
}
Step 2 — Add a custom text converter
When using RichText from @payloadcms/richtext-lexical/react, override the default text converter to read the "$" key and apply the corresponding CSS as inline styles:
// src/components/RichText/index.tsx
import { textStateConfig } from '@/fields/textStateConfig'
import {
JSXConvertersFunction,
RichText as ConvertRichText,
} from '@payloadcms/richtext-lexical/react'
// Lexical serializes node state under the "$" key.
const NODE_STATE_KEY = '$'
// React's style prop requires camelCase, but TextStateFeature CSS uses hyphen-case.
function hyphenToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
}
const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
...defaultConverters,
text: (args) => {
const { node } = args
// Render standard formatting (bold, italic, etc.) using the default converter
let text =
typeof defaultConverters.text === 'function'
? defaultConverters.text(args)
: node.text
// Apply TextStateFeature styles from the "$" key in the serialized node
const nodeState = (node as any)[NODE_STATE_KEY] as
| Record<string, string>
| undefined
if (nodeState) {
const styles: React.CSSProperties = {}
for (const [stateKey, stateValue] of Object.entries(nodeState)) {
const css = (textStateConfig as any)[stateKey]?.[stateValue]?.css
if (css) {
for (const [prop, value] of Object.entries(css)) {
;(styles as any)[hyphenToCamel(prop)] = value
}
}
}
if (Object.keys(styles).length > 0) {
text = <span style={styles}>{text}</span>
}
}
return text
},
})
export default function RichText({ data, ...rest }) {
return <ConvertRichText converters={jsxConverters} data={data} {...rest} />
}
The key insight is that textStateConfig is the single source of truth — it is passed directly to TextStateFeature in your field config and also imported by your frontend converter to resolve the CSS values at render time.