Skip to content

Rich Text

The RichText field renders a WYSIWYG editor with a configurable toolbar. It submits an HTML string, or null when the editor is empty. The PHP field adds a string rule automatically, so optional editors should also call nullable().

Basic Usage

Example
php
use InertiaUI\Forms\Fields\RichText;

RichText::make('body')->nullable();

This renders an editor with the default toolbar: bold, italic, underline, strike, an H2 heading, lists, links, blockquotes, inline code, undo, and redo.

Toolbar Presets

Simple

A minimal toolbar with just bold, italic, and link:

Example
php
RichText::make('bio')->simple();

Full

The full preset adds the larger editor controls, including image embeds, task lists, and text alignment:

php
RichText::make('content')->full();

Custom

Pass your own toolbar configuration:

Example
php
RichText::make('notes')
    ->toolbar('bold italic | bullet ordered ~ link undo redo');

You may also pass an array. Each nested array becomes a visual button group:

php
RichText::make('notes')->toolbar([
    ['bold', 'italic'],
    ['bulletList', 'orderedList'],
    ['link'],
]);

Flat arrays are accepted too:

php
RichText::make('notes')->toolbar([
    'bold', 'italic', '|', 'bulletList', 'orderedList', '|', 'link',
]);

Use | as a visual separator between button groups and ~ as a flexible spacer. The string grammar also accepts these aliases: bullet for bulletList, ordered for orderedList, quote for blockquote, hr for horizontalRule, and h1, h2, or h3 for heading.

RichText::normalizeToolbar() exposes the same normalization when application code needs the canonical flat toolbar array without building a field instance.

h1, h2, and h3 all normalize to the single heading toolbar control. That control toggles an H2 heading; it is not a heading-level picker.

Available Toolbar Items

ItemDescription
boldBold text
italicItalic text
underlineUnderlined text
strikeStrikethrough text
headingToggle an H2 heading
highlightHighlighted text
subscriptSubscript text
superscriptSuperscript text
bulletListUnordered list
orderedListNumbered list
taskListCheckbox list
linkHyperlink
imageImage embed
blockquoteBlock quote
codeInline code
codeBlockCode block
horizontalRuleHorizontal divider
alignLeftLeft alignment
alignCenterCenter alignment
alignRightRight alignment
undoUndo
redoRedo

The link toolbar item opens a native modal dialog for inserting, editing, and removing links. Configure the generated link policy from PHP:

php
RichText::make('body')
    ->simple()
    ->linkTargetBlank()
    ->linkNoopener()
    ->linkNoreferrer()
    ->linkAllowedProtocols(['https', 'mailto'])
    ->linkDefaultProtocol('https');

By default, links open in the same tab, noopener is enabled for links that open in a new tab, noreferrer is disabled, auto-linking and link-on-paste are enabled, the default protocol is http, and allowed protocols are http, https, mailto, and tel.

Use autoLink(false) to stop converting typed URLs and linkOnPaste(false) to stop converting a pasted URL. These switches do not disable the link dialog. linkAllowedProtocols() normalizes and deduplicates protocol names. Removing the current default protocol makes the first allowed protocol the new default. linkDefaultProtocol() must select a protocol that is already allowed.

Use allowLinkTargetOverride(false) when the field configuration should fully control whether links open in a new tab. With target override allowed, authors may toggle "Open in new tab" per link.

php
RichText::make('body')
    ->linkTargetBlank()
    ->allowLinkTargetOverride(false);

Use linkRel() for additional rel tokens such as nofollow, sponsored, or ugc:

php
RichText::make('body')
    ->linkRel(['nofollow', 'ugc']);

Protocol options are validated when the form is built. Unsafe or malformed protocols such as javascript: and data: are rejected, and the editor also rejects links whose protocol is not allowed. This policy applies to links created or normalized by the editor; it is not a replacement for server-side HTML sanitization when accepting untrusted HTML.

Height

Set minimum and/or maximum heights for the editor area in pixels:

php
RichText::make('content')
    ->minHeight(200)
    ->maxHeight(600);

Image Embeds

The image toolbar item accepts image URLs by default. Add imageUploads() when authors should also be able to pick an image file from their device:

php
use InertiaUI\Forms\Fields\RichText;
use InertiaUI\Forms\FileUploads\UploadConfig;

RichText::make('body')
    ->full()
    ->imageUploads(fn (UploadConfig $uploads) => $uploads
        ->maxSize(2048)
        ->maxDimensions(width: 2400, height: 1600));

RichText image uploads use the same temporary upload pipeline as the File field. Configure custom routes, chunking, direct-to-storage uploads, upload validators, and token validation in File Uploads. Common image validation helpers such as accept(), maxSize(), minSize(), dimensions(), minDimensions(), and maxDimensions() run during the upload request. The upload config always includes Laravel's image rule; accept() narrows the allowed image MIME types and must not turn this into a general file upload.

The generated companion field is <name>_images, for example body_images. Use getImageUploadFieldName() on the field or RichTextUploads::imageUploadFieldName('body') when application code needs that name explicitly.

storeWithForm() is not supported for RichText images. RichText still submits an HTML string for the field itself. Uploaded images are tracked in a companion token list, so your controller may resolve the temporary uploads and rewrite the HTML before storing it.

Image Resizing

Images inserted by URL or uploaded through imageUploads() may be resized in the editor with corner handles. The editor stores the user's resized display size as numeric width and height attributes on the <img> tag, so the submitted value stays regular HTML. RichTextUploads and RichTextContent preserve those attributes when they rewrite temporary upload markers or stored-image markers.

Storage and rendering callbacks receive the existing image attributes through the RichTextImage builder. Use RichTextImage::width(), height(), or dimensions() when your application wants to normalize or override the user's resized display size while storing or rendering an image. These fluent setters write the rendered <img width height> attributes. They are not getters and do not describe the source image's intrinsic pixel dimensions. Use upload validation helpers such as UploadConfig::dimensions() and maxDimensions() when you need to validate the actual uploaded file.

Store Uploaded Images

Use RichTextUploads in your controller or form request handler. The helper compares the data-inertia-forms-upload markers in the submitted HTML with the companion token list before storage callbacks run. It then passes each validated SubmittedUpload to your storage callback with a RichTextImage builder, rewrites the matching <img> tags, and deletes the temporary uploads after the rewrite succeeds:

php
use InertiaUI\Forms\FileUploads\SubmittedUpload;
use InertiaUI\Forms\RichText\RichTextImage;
use InertiaUI\Forms\RichText\RichTextUploads;

$html = RichTextUploads::from($request, 'body')
    ->storeImagesUsing(function (SubmittedUpload $upload, RichTextImage $image): RichTextImage {
        $storedImage = app(ContentImageStore::class)->store($upload->getUploadedFile());

        return $image
            ->src($storedImage->url())
            ->identifier($storedImage->uuid(), [
                'id' => $storedImage->id(),
            ]);
    })
    ->toHtml();

The callback must return the provided RichTextImage builder, a new RichTextImage instance, or null to keep the builder as-is. Returning arrays is intentionally unsupported so the replacement shape stays typed and readable.

RichTextImage may set src(), alt(), title(), width(), height(), or both dimensions together. attribute() and attributes() set other syntactically valid image attributes; pass null or false to remove one. identifier() writes a durable application-owned identifier plus optional metadata for deferred rendering, and toAttributes() returns the final attribute map.

Spatie Media Library

For models that use Spatie Media Library, use the convenience helper:

php
use InertiaUI\Forms\RichText\RichTextUploads;

$html = RichTextUploads::from($request, 'body')
    ->storeImagesInMediaLibrary($post, 'content-images')
    ->toHtml();

The helper stores each uploaded image in the given media collection, rewrites the image src to the media URL, and records a generic stored-image identifier so the HTML may also be resolved later. Its optional third disk argument overrides the media collection's storage disk. Leave it empty to use the collection's configured disk.

Deferred Image Rendering

Use keepTokenized() when the stored HTML should keep app-owned image references instead of final URLs. This is useful when responsive images are generated on a queue, when image URLs should be created during rendering, or when the final URL depends on the current tenant or viewer.

php
use InertiaUI\Forms\RichText\RichTextUploads;

$post->body = RichTextUploads::from($request, 'body')
    ->keepTokenized()
    ->storeImagesInMediaLibrary($post, 'content-images')
    ->toHtml();

Later, replace stored image markers while rendering:

php
use InertiaUI\Forms\RichText\RichTextContent;
use InertiaUI\Forms\RichText\RichTextImage;
use InertiaUI\Forms\RichText\RichTextStoredImage;

$html = RichTextContent::from($post->body)
    ->replaceImagesUsing(function (RichTextStoredImage $stored, RichTextImage $image): RichTextImage {
        return $image->src(route('content-images.show', $stored->identifier()));
    })
    ->toHtml();

The RichTextStoredImage passed to the callback exposes identifier(), all metadata(), a nested meta($key, $default) lookup, and the original image attributes(). Returning null from the render callback keeps the original tokenized image unchanged. RichTextContent::toHtml() returns the rewritten HTML string or null when its input was null.

Stored images use the package-owned data-inertia-forms-image marker as a durable application reference. The marker stores the identifier and optional metadata returned by your storage callback or Media Library helper, not the temporary upload token. Treat those identifiers as application data: resolve and authorize them in your RichTextContent::from(...)->replaceImagesUsing(...) callback before rendering a URL.

Continue to sanitize untrusted HTML according to your application's policy before storing or rendering it. Image upload validation only validates the uploaded files; it does not sanitize arbitrary HTML submitted in the RichText value. Sanitizers that run before RichTextUploads should allow supported image attributes, usually src, alt, title, width, height, and data-inertia-forms-upload. Tokenized stored images also need data-inertia-forms-image before RichTextContent renders them.

Word and Character Count

Word Count

php
RichText::make('content')->showWordCount();

Character Count

php
RichText::make('content')->showCharacterCount();

Max Length

Set a character limit for the editor text:

php
RichText::make('content')
    ->maxLength(5000)
    ->showCharacterCount();

maxLength() is serialized to the editor and configures Tiptap's CharacterCount extension. It limits the visible text the editor counts, and showCharacterCount() makes that count visible to the user.

It does not add a Laravel validation rule by itself. RichText submits an HTML string, or null for an empty editor, so Laravel string length rules measure the submitted markup as well as the visible text. Add your own server-side rules for the submitted value, sanitize untrusted HTML before storing or rendering it, and use a custom rule or FormRequest hook if the server must enforce a visible-text limit:

php
RichText::make('content')
    ->maxLength(5000)
    ->showCharacterCount()
    ->rules(['nullable', 'string']);
php
$request->validate([
    'content' => ['nullable', 'string'],
]);

$html = $request->string('content')->toString();

// Sanitize the HTML with your application's approved sanitizer before storing.
// Count text extracted from sanitized HTML when enforcing a visible-text limit.

Article Editor Configuration

Use placeholder() to set the empty-editor prompt:

php
RichText::make('body')
    ->label('Article Body')
    ->placeholder('Start writing...')
    ->full()
    ->linkAllowedProtocols(['https', 'mailto'])
    ->linkDefaultProtocol('https')
    ->linkTargetBlank()
    ->linkNoopener()
    ->minHeight(300)
    ->maxHeight(800)
    ->showWordCount()
    ->showCharacterCount()
    ->maxLength(10000)
    ->required();

RichText-specific methods above compose with shared field APIs. Use Form Class, Model Binding, Validation, Conditional Visibility, Authorization, and Styling for labels, help, defaults, rules, visibility, authorization, layout, and classes. RichText image uploads use the same runtime configuration described in File Uploads.

Using the Component Directly

You may render RichText from each stack's richtext subpath with local HTML state, or pass serialized field props during manual form rendering.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  RichText,
  type RichTextLinkOptions,
  type RichTextToolbar,
} from '@inertiaui/form-vue/richtext'

const body = ref<string | null>('<p>Draft body</p>')
const locked = ref(false)
const hasError = ref(false)

const toolbar: RichTextToolbar = [
  'bold',
  'italic',
  '|',
  'bulletList',
  'orderedList',
  '|',
  'link',
  'image',
  '|',
  'undo',
  'redo',
]

const linkOptions: RichTextLinkOptions = {
  targetBlank: true,
  noopener: true,
  noreferrer: true,
  rel: ['nofollow'],
  allowedProtocols: ['https', 'mailto'],
  defaultProtocol: 'https',
  autoLink: true,
  linkOnPaste: true,
  allowTargetOverride: false,
}

const editorParts = {
  toolbar: 'border-b border-zinc-200',
  toolbarButton: 'data-[active=true]:text-blue-600',
  editor: 'min-h-48',
  footer: 'text-xs',
  dialog: 'max-w-lg',
}
</script>

<template>
  <RichText
    v-model="body"
    id="article-body"
    name="body"
    label="Article body"
    help="Draft the public article body."
    placeholder="Start writing..."
    :toolbar="toolbar"
    :min-height="300"
    :max-height="800"
    show-word-count
    show-character-count
    :max-length="10000"
    :link-options="linkOptions"
    required
    precognitive
    :disabled="locked"
    :invalid="hasError"
    :error="hasError ? 'Article body is required.' : null"
    badge="Draft"
    badge-class="bg-zinc-100 text-zinc-700"
    label-trailing="Required"
    label-trailing-class="text-zinc-500"
    tooltip="Shown on the article page."
    help-position="below"
    layout="stacked"
    control-position="end"
    label-class="font-medium"
    help-class="text-zinc-500"
    error-class="text-red-600"
    wrapper-class="max-w-3xl"
    control-class="bg-white"
    class="font-medium"
    :part-classes="editorParts"
  />
</template>
tsx
import { useState } from 'react'
import {
    RichText,
    type RichTextLinkOptions,
    type RichTextToolbar,
} from '@inertiaui/form-react/richtext'

const toolbar: RichTextToolbar = [
    'bold',
    'italic',
    '|',
    'bulletList',
    'orderedList',
    '|',
    'link',
    'image',
    '|',
    'undo',
    'redo',
]

const linkOptions: RichTextLinkOptions = {
    targetBlank: true,
    noopener: true,
    noreferrer: true,
    rel: ['nofollow'],
    allowedProtocols: ['https', 'mailto'],
    defaultProtocol: 'https',
    autoLink: true,
    linkOnPaste: true,
    allowTargetOverride: false,
}

const editorParts = {
    toolbar: 'border-b border-zinc-200',
    toolbarButton: 'data-[active=true]:text-blue-600',
    editor: 'min-h-48',
    footer: 'text-xs',
    dialog: 'max-w-lg',
}

export default function ArticleEditor() {
    const [body, setBody] = useState<string | null>('<p>Draft body</p>')
    const [locked] = useState(false)
    const [hasError] = useState(false)

    return (
        <RichText
            value={body}
            onValueChange={setBody}
            id="article-body"
            name="body"
            label="Article body"
            help="Draft the public article body."
            placeholder="Start writing..."
            toolbar={toolbar}
            minHeight={300}
            maxHeight={800}
            showWordCount
            showCharacterCount
            maxLength={10000}
            linkOptions={linkOptions}
            required
            precognitive
            disabled={locked}
            invalid={hasError}
            error={hasError ? 'Article body is required.' : null}
            badge="Draft"
            badgeClass="bg-zinc-100 text-zinc-700"
            labelTrailing="Required"
            labelTrailingClass="text-zinc-500"
            tooltip="Shown on the article page."
            helpPosition="below"
            layout="stacked"
            controlPosition="end"
            labelClass="font-medium"
            helpClass="text-zinc-500"
            errorClass="text-red-600"
            wrapperClass="max-w-3xl"
            controlClass="bg-white"
            class="font-medium"
            partClasses={editorParts}
        />
    )
}

RichText is a composite editor and does not forward arbitrary native attributes to an inner native element. See Native Attributes And Events. Image-upload runtime configuration uses File Uploads.

Editor Support Components

The richtext subpath also exports EditorShell, EditorToolbar, and EditorCountFooter with their prop types. These low-level presenters let custom RichText integrations reuse the package shell, toolbar semantics, count footer, native attributes, and styling hooks without pulling Tiptap into the root or components entry points.

ComponentPublic options
EditorShellname writes data-editor-name; focused, invalid, and disabled select semantic states; classValue and controlClass extend shell classes. Vue provides toolbar, default, and footer slots. React renders children. Both forward native wrapper attributes.
EditorToolbaritems is the normalized control/separator/spacer order; buttons supplies each control's label, state, and action; disabled disables every control; dataAttributePrefix changes generated test/data attribute names; classValue and buttonClass extend toolbar styling. React additionally receives an icons map; Vue toolbar buttons carry their icon components. Native wrapper attributes are forwarded.
EditorCountFooterfooter.show controls rendering; wordLabel, characterLabel, and characterLimitLabel provide already-formatted display text; classValue extends footer styling. Both implementations forward native wrapper attributes.

For broader presenter customization, copy the shipped RichText presenter and see Customizing Presenters.