If you've spent any time migrating editors or building rich-text features, you'll know Tiptap looks great on paper but sometimes feels a little... minimal compared to what TinyMCE shipped out of the box. At ilionx we've been moving a few large sites to Umbraco 17 and bumped into the same feedback: list style options. Nothing dramatic, just the ability to pick Roman numerals, different bullet shapes, that sort of thing, but enough to make editors ask for the old behavior back.
So we wrote a small TypeScript extension that adds back list-style variants. It gets built to App_Plugins with Vite, plugs into the UmbTiptapExtensionApiBase and wires up extra toolbar menu items. The actual extension is tiny, it just adds a listStyleType attribute to the list nodes and renders it as inline CSS, but the surrounding bits (icons, toolbar menus, wiring) make the UX feel polished.
Tiptap has solid docs on extending and creating extensions, and Umbraco documents how to register a Tiptap extension, but stitching everything together took a little trial and error. Below is the extension configuration we used. It looks like a lot for a simple feature, but a fair chunk of the files are just icons and small glue code.
Let’s start with the actual extension definitions in Umbraco-package.json, where we define 4 extensions:
"extensions": [
{
"type": "icons",
"alias": "My.Icons.TiptapListStyle",
"js": "/App_Plugins/My.TiptapListStyle/list-style-icons.js"
},
{
"type": "tiptapExtension",
"alias": "My.Tiptap.ListStyle",
"api": "/App_Plugins/My.TiptapListStyle/list-style.tiptap-api.js",
"meta": {
"icon": "icon-ordered-list",
"label": "List style type",
"group": "#tiptap_extGroup_formatting"
}
},
{
"type": "tiptapToolbarExtension",
"kind": "menu",
"alias": "My.Tiptap.Toolbar.OrderedList",
"overwrites": "Umb.Tiptap.Toolbar.OrderedList",
"api": "/App_Plugins/ My.TiptapListStyle/ordered-list-menu.tiptap-toolbar-api.js",
"forExtensions": ["My.Tiptap.ListStyle", " My.Tiptap.OrderedList"],
"items": [
{
"label": "Numbered list",
"data": null,
"appearance": { "icon": "icon-ordered-list" }
},
{
"label": "Numbered list: i, ii, iii",
"data": "lower-roman",
"appearance": { "icon": "icon-list-style-lower-roman" }
},
// add other style definitions here
]
},
{
"type": "tiptapToolbarExtension",
"kind": "menu",
"alias": " My.Tiptap.Toolbar.BulletList",
"overwrites": "Umb.Tiptap.Toolbar.BulletList",
"api": "/App_Plugins/My.TiptapListStyle/bullet-list-menu.tiptap-toolbar-api.js",
"forExtensions": ["My.Tiptap.ListStyle", "Umb.Tiptap.BulletList"],
"items": [
{
"label": "Bullet list",
"data": null,
"appearance": { "icon": "icon-bulleted-list" }
},
{
"label": "Bullet list: circle",
"data": "circle",
"appearance": { "icon": "icon-list-style-bullet-circle" }
},
// add other style definitions here
]
}
]
Here’s what we register:
- an icon set (a bunch of tiny SVGs showing what each list style looks like)
- one Tiptap extension which carries the list-style attribute
- two toolbar extensions that replace the ordered/bulleted list buttons with menus that expose the variants.
The icon set
The icons are trivial: I asked an AI to generate SVGs for different list markers and put each one in a tiny TypeScript file that exports the SVG string.
/** Ordered list with lower-roman markers (i, ii, iii). */
export default `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"><text x="1" y="7.5" font-size="4.5" fill="currentColor" stroke="none" font-family="serif" font-style="italic">i.</text><path d="M9 6h12"/><text x="1" y="13.5" font-size="4.5" fill="currentColor" stroke="none" font-family="serif" font-style="italic">ii.</text><path d="M9 12h12"/><text x="1" y="19.5" font-size="4.5" fill="currentColor" stroke="none" font-family="serif" font-style="italic">iii.</text><path d="M9 18h12"/></svg>`;
And a small registry file maps the icon names to those files:
export default [
{
name: 'icon-list-style-lower-roman',
path: () => import('./icons/svg-ordered-list-lower-roman.js'),
},
//...
];
These images are purely to make the toolbar menus readable — a little UX polish so editors instantly recognise the option they’re picking.
The Tiptap extension
The extension itself is surprisingly compact. It adds a listStyleType attribute to the built-in orderedList and bulletList nodes and serialises it as inline list-style-type style so the HTML stays portable and renders the same in the backoffice and on the front end.
import { Extension, UmbTiptapExtensionApiBase } from '@umbraco-cms/backoffice/tiptap';
/**
* Extends the built-in orderedList and bulletList nodes with a `listStyleType`
* attribute that maps to the CSS `list-style-type` property.
*
* The attribute is serialised as an inline style so the stored HTML is portable
* and renders correctly both in the backoffice editor and on the front end.
*/
const ListStyleExtension = Extension.create({
name: 'listStyle',
addGlobalAttributes() {
return [
{
types: ['orderedList', 'bulletList'],
attributes: {
listStyleType: {
default: null,
parseHTML: (element) => element.style.listStyleType || null,
renderHTML: (attributes) => {
if (!attributes['listStyleType']) return {};
return { style: `list-style-type: ${attributes['listStyleType']}` };
},
},
},
},
];
},
});
export default class ListStyleTiptapExtensionApi extends UmbTiptapExtensionApiBase {
constructor(...args: ConstructorParameters<typeof UmbTiptapExtensionApiBase>) {
super(...args);
this.getTiptapExtensions = () => [ListStyleExtension];
}
}
That’s it, the extension maps an attribute to CSS and makes sure works in both front- and backoffice. Nothing scary.
The toolbar extensions
Because ordered and unordered lists are separate toolbar buttons, we overwrite each one with a menu that exposes the style variants. To avoid duplicating logic we put the toolbar behaviour into a single helper and import it from the two small extension files.
Each toolbar menu has to do a few things:
- create a list if the selection isn’t in one
- switch between ordered and bullet lists
- toggle or set a specific list-style-type and reflect the active state in the menu UI.
That last bit is where the logic concentrates, the UI needs to show which style is active and allow toggling back to the default behaviour.
The per-list-type extension files look like this:
import { createListStyleMenuToolbarApi } from './list-style-menu-toolbar-api.js';
export default createListStyleMenuToolbarApi('bulletList');
And the shared toolbar helper contains the actual implementation and state logic:
import type { Editor } from '@umbraco-cms/backoffice/tiptap';
import { UmbTiptapToolbarElementApiBase } from '@umbraco-cms/backoffice/tiptap';
type ListStyleMenuItem = { data?: string | null };
function getStyleType(item?: ListStyleMenuItem): string | null | undefined {
return item?.data;
}
function applyListStyle(
editor: Editor,
listType: 'orderedList' | 'bulletList',
styleType: string,
): void {
if (editor.isActive(listType)) {
const currentStyle = editor.getAttributes(listType)['listStyleType'] as string | null;
const newStyle = currentStyle === styleType ? null : styleType;
editor.chain().focus().updateAttributes(listType, { listStyleType: newStyle }).run();
return;
}
if (listType === 'orderedList') {
editor
.chain()
.focus()
.toggleOrderedList()
.updateAttributes('orderedList', { listStyleType: styleType })
.run();
} else {
editor
.chain()
.focus()
.toggleBulletList()
.updateAttributes('bulletList', { listStyleType: styleType })
.run();
}
}
function applyDefaultList(editor: Editor, listType: 'orderedList' | 'bulletList'): void {
if (editor.isActive(listType)) {
const currentStyle = editor.getAttributes(listType)['listStyleType'] as string | null;
if (currentStyle) {
editor.chain().focus().updateAttributes(listType, { listStyleType: null }).run();
return;
}
if (listType === 'orderedList') {
editor.chain().focus().toggleOrderedList().run();
} else {
editor.chain().focus().toggleBulletList().run();
}
return;
}
if (listType === 'orderedList') {
editor.chain().focus().toggleOrderedList().run();
} else {
editor.chain().focus().toggleBulletList().run();
}
}
/**
* Toolbar menu API for ordered/bullet list buttons with list-style-type variants.
*/
export function createListStyleMenuToolbarApi(listType: 'orderedList' | 'bulletList') {
return class extends UmbTiptapToolbarElementApiBase {
override isActive(editor?: Editor, item?: ListStyleMenuItem): boolean {
if (!editor) return false;
const styleType = getStyleType(item);
if (styleType === undefined) {
return editor.isActive(listType);
}
if (styleType === null) {
const currentStyle = editor.getAttributes(listType)['listStyleType'] as string | null;
return editor.isActive(listType) && currentStyle === null;
}
return editor.isActive(listType, { listStyleType: styleType });
}
override execute(editor?: Editor, item?: ListStyleMenuItem): void {
if (!editor) return;
const styleType = getStyleType(item);
if (styleType === undefined || styleType === null) {
applyDefaultList(editor, listType);
return;
}
applyListStyle(editor, listType, styleType);
}
};
}
TODO / caveats
I’m still getting comfortable with TypeScript, so this isn't a 100%-polished extension. One known quirk: clicking a custom style button after setting a style will clear the style but not fully remove the list node the way the stock ordered/bullet button does. It's a small UX edge-case and likely fixable by tweaking the toggle logic, but I haven't had time for it yet.
If you want to copy this approach, the important bits are:
- expose a
listStyleTypeattribute on the list nodes, - serialize it in a way that works in both editor and front end (inline style is the simplest)
- replace the toolbar buttons with menus that set/update that attribute.
Hopefully this saves you a few hours of experimentation. The overall idea is small, but the payoff for editors is surprisingly nice.



Top comments (0)