# Rich Text

> Configure custom formats for the WYSIWYG interface, understand what the editor stores, and style the resulting HTML in your app's frontend CSS.

The [WYSIWYG interface](/guides/data-model/interfaces#wysiwyg) stores its value as HTML. This page covers the **Custom Formats** option, which adds your own formatting entries to the editor.

<callout icon="i-lucide-info">

The editor is built on [Tiptap](https://tiptap.dev) as of Directus 12. It stores only the markup its schema models. See [Version 12 breaking changes](/releases/breaking-changes/version-12#wysiwyg-editor-rebuilt-on-tiptap) for the supported HTML and how existing values are normalized.

</callout>

## Custom Formats

**Custom Formats** takes a JSON array of formatting entries. Set it on the field's interface options. When the array is not empty, a **Formats** dropdown is added to the editor toolbar. There is no toolbar option to enable it.

The format is a subset of TinyMCE's `style_formats`, so existing configurations often carry over unchanged.

Every entry needs a `title` and one of the following keys:

<table>
<thead>
  <tr>
    <th>
      Key
    </th>
    
    <th>
      Applies to
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        inline
      </code>
    </td>
    
    <td>
      A tag wrapped around the selected text, such as <code>
        span
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        block
      </code>
    </td>
    
    <td>
      A single block tag. Converts the block when its tag differs. See <a href="#block-formats">
        Block formats
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        selector
      </code>
    </td>
    
    <td>
      One or more comma-separated block tags. Never converts the block.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        items
      </code>
    </td>
    
    <td>
      An array of entries, grouped into a submenu. See <a href="#grouped-formats">
        Grouped formats
      </a>
      
      .
    </td>
  </tr>
</tbody>
</table>

An `inline`, `block`, or `selector` entry also needs at least one of `classes` or `attributes`, and can set both. The editor uses them to recognize the format again when the value is reloaded, so an entry carrying neither is skipped. An `items` group is a container and needs neither.

`styles` is the only optional key.

### Example

```json
[
  { "title": "Lead paragraph", "block": "p", "classes": "lead" },
  { "title": "Subheading", "selector": "h2,h3", "classes": "subheading" },
  {
    "title": "Callouts",
    "items": [
      { "title": "Info", "block": "p", "classes": "callout callout-info" },
      { "title": "Warning", "block": "p", "classes": "callout callout-warning" }
    ]
  },
  {
    "title": "Highlight",
    "inline": "span",
    "classes": "highlight",
    "styles": { "background": "#ffff00" }
  }
]
```

Applying **Lead paragraph** to a paragraph, then **Highlight** to the word `lead` inside it, turns:

```html
<p>A lead paragraph.</p>
```

into:

```html
<p class="lead">A <span class="highlight" style="background: #ffff00">lead</span> paragraph.</p>
```

Define `lead`, `subheading`, `callout`, and `highlight` in your frontend's CSS. Directus stores the classes but does not style them. See [Styling the output on your frontend](#styling-the-output-on-your-frontend).

### Inline formats

An `inline` entry renders a tag around the selection, carrying its `classes`, its `attributes`, and its `styles` serialized into a `style` attribute:

```json
{
  "title": "Highlight",
  "inline": "span",
  "classes": "highlight",
  "styles": { "background": "#ffff00" },
  "attributes": { "title": "Highlighted text" }
}
```

Selecting the word `text` and applying the format turns:

```html
<p>Some text.</p>
```

into:

```html
<p>Some <span class="highlight" style="background: #ffff00" title="Highlighted text">text</span>.</p>
```

Inline entries are the only formats that write a `style` attribute into the content, so they are also the only ones with a visible effect inside the editor. The trade-off is an inline style in your stored HTML, which is harder to override from your frontend CSS.

### Block formats

A `block` entry applies to one block tag and takes a single tag only. Use `selector` for a list.

```json
{ "title": "Lead paragraph", "block": "p", "classes": "lead" }
```

When the selected block's tag differs from the entry's tag, the block is converted. **Conversion works between paragraphs and headings only**, because those are the two block types that hold the same content.

With `{ "title": "Subheading", "block": "h2", "classes": "subheading" }`, applying the format to a paragraph turns:

```html
<p>Section title</p>
```

into:

```html
<h2 class="subheading">Section title</h2>
```

A `block: 'p'` entry converts the other way, turning a heading back into a paragraph.

Every other tag applies without converting. With `{ "title": "Pull quote", "block": "blockquote", "classes": "pull-quote" }`, applying the format to a block that is already a `<blockquote>` turns:

```html
<blockquote>A quote.</blockquote>
```

into:

```html
<blockquote class="pull-quote">A quote.</blockquote>
```

Applying it to a paragraph leaves the paragraph untouched, and the editor logs a warning in the browser console when it loads the format. Use `selector` when you never want conversion.

### Selector formats

A `selector` entry applies to the block tags you list and never changes the block's type:

```json
{ "title": "Subheading", "selector": "h2,h3", "classes": "subheading" }
```

Applying it to an `<h2>` or `<h3>` adds the class, turning:

```html
<h3>Section title</h3>
```

into:

```html
<h3 class="subheading">Section title</h3>
```

Applying it to a paragraph does nothing, because `p` is not in the entry's list:

```html
<p>Section title</p>
```

stays:

```html
<p>Section title</p>
```

### Grouped formats

An `items` array groups entries into a submenu in the **Formats** dropdown:

```json
{
  "title": "Callouts",
  "items": [
    { "title": "Info", "block": "p", "classes": "callout callout-info" },
    { "title": "Warning", "block": "p", "classes": "callout callout-warning" }
  ]
}
```

Groups support one level. A group nested inside another group is skipped, and a group whose entries are all invalid is dropped along with them.

### Which tags a block format can target

`block` and `selector` accept only tags the editor models as a node:

`p`, `h1` through `h6`, `pre`, `blockquote`, `section`, `article`, `figure`, `figcaption`, `details`, `summary`, `dl`, `dt`, `dd`, `hr`, `img`, `ul`, `ol`, `li`, `table`, `tr`, `td`, `th`, `video`, `audio`, `iframe`.

`div` and `span` are not on the list, so they cannot carry a block format. A `block: 'div'` entry is skipped. This matters when you migrate a TinyMCE configuration, since TinyMCE formats commonly wrap content in a `div`. It is the same reason a stored `<div class="float-left">` is unwrapped when the editor loads it. Use a `section`, `article`, or `figure` entry instead, or move the wrapper into your frontend template.

A `selector` entry drops the tags the editor does not model and keeps the rest, so `selector: 'h2,div,p'` still applies to `<h2>` and `<p>`. The entry is skipped only when none of its tags are modelled.

`selector` takes plain tag names. Compound CSS selectors such as `p.lead`, `div > p`, and `#main` are skipped rather than silently matching nothing.

### Invalid entries are skipped

An entry the editor cannot build is skipped, and the rest of the array still loads. Every skipped entry logs a warning to the **browser console**. Nothing appears in the interface, so check the console when a format does not show up in the dropdown.

Entries are skipped when they:

- Have no `title`.
- Have none of `inline`, `block`, `selector`, or `items`.
- Have no `classes` and no `attributes`, unless the entry is an `items` group.
- Use `wrapper`, which is not supported.
- Name only tags the editor does not model, such as `block: 'div'` or `selector: 'div,span'`.
- Pass a compound CSS selector such as `p.lead` or `#main` instead of a plain tag name.
- Pass a comma-separated list to `block` instead of `selector`.
- Nest a group inside another group.

## Gotchas

### The editor does not load your frontend CSS

<callout icon="i-lucide-triangle-alert" color="warning">

The editor is not a preview of your site. Classes applied through custom formats have no styling attached in the Data Studio. The exception is [inline formats](#inline-formats), which write a `style` attribute into the content and do render in the editor.

</callout>

Directus loads no project stylesheet into the editing surface, so a class like `lead`, `float-left`, or `text-center` has no visual effect inside the WYSIWYG. The class is stored and round-trips correctly. It just has nothing styling it in the Data Studio.

This surprises authors, who apply a format, see no change, and assume it is broken. Point them at the **Formats** dropdown instead: it shows the active format in its label and highlights the matching row. That is the confirmation that a format applied, not the rendered content.

The editor's own content styles can also contradict your class. The editor styles `figure` as `display: table; margin: 0.8125rem auto`, so a captioned image with a `float-left` class renders centered in the editor and floated on your frontend. The same applies to anything depending on `display`, `float`, `width`, or a flex or grid context your frontend provides.

There is currently no option to inject custom CSS into the editor. The deprecated **Options Override** (`tinymceOverrides`) option is no longer applied, so TinyMCE's `content_css` has no equivalent.

Because of this, layout classes are the riskiest thing to hand to non-technical authors: no feedback in the editor, and a result that depends entirely on CSS the editor cannot see. Prefer semantic formats such as `lead`, `callout`, or `subheading` over layout ones where you can.

### `styles` on a block entry only styles the dropdown

On an `inline` entry, `styles` is written into the content as a `style` attribute. On a `block` or `selector` entry, `styles` only styles the entry's label inside the **Formats** dropdown. It is not written to the content.

The editor does not round-trip `style` on block nodes. Only `class`, `id`, `title`, `role`, `lang`, `dir`, `data-*`, and `aria-*` survive a save and load cycle. TinyMCE applied block `styles` to the block itself, so this is a behavior change to account for when migrating.

Use `classes` for block styling and define the CSS on your frontend.

### Attributes on a block entry are filtered

`attributes` on a `block` or `selector` entry accepts only `id`, `title`, `role`, `lang`, `dir`, `data-*`, and `aria-*`. Anything else, including `style` and `target`, is dropped with a console warning.

`attributes: { "class": "…" }` is merged into `classes`, so toggling the format off removes those classes too.

Inline entries do not filter `attributes`.

## Toggling behavior

Selecting an active format in the dropdown removes it. Applying and removing a block format behaves as follows:

- Removing a format strips only the classes and attributes that format configured. Unrelated classes, `id`, `data-*`, and `aria-*` on the same block survive.
- The block keeps its tag. A `block: 'h2'` format does not turn the heading back into a paragraph when you remove it.
- A format applies to every eligible block in the selection as a single undo step.
- A format counts as active when every eligible block in the selection carries all of its classes, or all of its attributes for a format anchored on attributes alone.

## Image captions

Adding or editing an image opens a drawer with a **Caption** field. A non-empty caption wraps the image in a `<figure>`:

```html
<figure>
  <img src="https://example.com/assets/2b1a…" alt="A wind turbine" />
  <figcaption>A wind turbine at dusk.</figcaption>
</figure>
```

Style `figure` and `figcaption` on your frontend to match. The editor centers them, which your own CSS will override.

Behavior worth knowing:

- Clearing the caption reverts to a bare `<img>`, but only when the `<figure>` holds nothing but images and carries no attributes of its own. A `<figure class="…">` you added deliberately is kept, and its caption is removed on its own.
- Editing an existing image updates it in place, so its attributes and its surrounding `<figure>` survive the edit.
- Pressing `Enter` inside a caption leaves the figure and starts a new paragraph after it, rather than adding a second `<figcaption>`.
- Pressing `Backspace` in an empty caption removes the caption.
- Deleting the image out of a figure removes the orphaned caption too.
- Stored `<figure>` and `<figcaption>` markup round-trips, including a caption placed before the image and a figure holding only a caption.

## Styling the output on your frontend

Directus stores the HTML. Rendering and styling it is your frontend's job.

Add the classes your formats configure to your stylesheet:

```css
.lead {
  font-size: 1.25rem;
  line-height: 1.6;
}

.subheading {
  color: #6644ff;
  text-transform: uppercase;
}

.callout {
  border-left: 4px solid;
  padding: 1rem;
}

.callout-info { border-color: #3399ff; }
.callout-warning { border-color: #ffaa00; }
```

Two things to keep in mind:

- Scope these styles so they do not collide with the rest of your site. Rendering the value inside a wrapper such as `.prose` and prefixing your selectors keeps author-applied classes from leaking.
- Review your selectors before upgrading to Directus 12 if you style stored HTML by tag, class, or attribute. Markup the editor does not model, such as `<div>` wrappers, is removed when a field is edited and saved.

## Next Steps

Read about the [WYSIWYG interface options](/guides/data-model/interfaces#wysiwyg), the [supported HTML and normalization behavior](/releases/breaking-changes/version-12#wysiwyg-editor-rebuilt-on-tiptap), and [keyboard shortcuts](/getting-started/accessibility) for the editor.
