Setup
Follow this guide to set up Novel with Tailwindcss
This example demonstrates the use of Shadcn-ui for ui, but alternative libraries and components can also be employed.
Shadcn-ui
You can find more info about installing shadcn-ui here. You will need to add the following components: Button, Separator, Popover, Command, Dialog
This example will use the same stucture from here: Anatomy
You can find the full example here: Tailwind Example
Configure Wrapper
"use client";
import { EditorContent, EditorRoot } from "novel";
import { useState } from "react";
const TailwindEditor = () => {
const [content, setContent] = useState(null);
return (
<EditorRoot>
<EditorContent
initialContent={content}
onUpdate={({ editor }) => {
const json = editor.getJSON();
setContent(json);
}}
/>
</EditorRoot>
);
};
export default TailwindEditor;
onUpdate
runs on every change. In most cases, you will want to debounce the updates to prevent too many state changes.
import { EditorInstance } from "novel"
...
const debouncedUpdates = useDebouncedCallback(async (editor: EditorInstance) => {
const json = editor.getJSON();
setContent(json);
setSaveStatus("Saved");
}, 500);
onUpdate={debouncedUpdates};
Configure Extensions
Extensions
You can find here example of extensions
import { defaultExtensions } from "./extensions";
const extensions = [...defaultExtensions];
<EditorContent
extensions={extensions}
...
/>;
Create Menus
Slash Command
Slash commands are a way to quickly insert content into the editor.
Bubble Menu
The bubble menu is a context menu that appears when you select text.
Add Editor Props
handleCommandNavigation
is required for fixing the arrow navigation in the / command;
import { handleCommandNavigation } from "novel/extensions";
import { defaultEditorProps, EditorContent } from "novel";
<EditorContent
...
editorProps={{
handleDOMEvents: {
keydown: (_view, event) => handleCommandNavigation(event),
},
attributes: {
class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
}
}}
/>
Add Styles
.ProseMirror {
@apply p-12 px-8 sm:px-12;
}
.ProseMirror .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: hsl(var(--muted-foreground));
pointer-events: none;
height: 0;
}
.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
color: hsl(var(--muted-foreground));
pointer-events: none;
height: 0;
}
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
&:hover {
cursor: pointer;
filter: brightness(90%);
}
&.ProseMirror-selectednode {
outline: 3px solid #5abbf7;
filter: brightness(90%);
}
}
.img-placeholder {
position: relative;
&:before {
content: "";
box-sizing: border-box;
position: absolute;
top: 50%;
left: 50%;
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid var(--novel-stone-200);
border-top-color: var(--novel-stone-800);
animation: spinning 0.6s linear infinite;
}
}
@keyframes spinning {
to {
transform: rotate(360deg);
}
}
/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
ul[data-type="taskList"] li > label {
margin-right: 0.2rem;
user-select: none;
}
@media screen and (max-width: 768px) {
ul[data-type="taskList"] li > label {
margin-right: 0.5rem;
}
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
background-color: hsl(var(--background));
margin: 0;
cursor: pointer;
width: 1.2em;
height: 1.2em;
position: relative;
top: 5px;
border: 2px solid hsl(var(--border));
margin-right: 0.3rem;
display: grid;
place-content: center;
&:hover {
background-color: hsl(var(--accent));
}
&:active {
background-color: hsl(var(--accent));
}
&::before {
content: "";
width: 0.65em;
height: 0.65em;
transform: scale(0);
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em;
transform-origin: center;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
&:checked::before {
transform: scale(1);
}
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: var(--muted-foreground);
text-decoration: line-through;
text-decoration-thickness: 2px;
}
/* Overwrite tippy-box original max-width */
.tippy-box {
max-width: 400px !important;
}
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
outline: none !important;
background-color: var(--novel-highlight-blue);
transition: background-color 0.2s;
box-shadow: none;
}
.drag-handle {
position: fixed;
opacity: 1;
transition: opacity ease-in 0.2s;
border-radius: 0.25rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);
background-repeat: no-repeat;
background-position: center;
width: 1.2rem;
height: 1.5rem;
z-index: 50;
cursor: grab;
&:hover {
background-color: var(--novel-stone-100);
transition: background-color 0.2s;
}
&:active {
background-color: var(--novel-stone-200);
transition: background-color 0.2s;
cursor: grabbing;
}
&.hide {
opacity: 0;
pointer-events: none;
}
@media screen and (max-width: 600px) {
display: none;
pointer-events: none;
}
}
.dark .drag-handle {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
}
/* Custom Youtube Video CSS */
iframe {
border: 8px solid #ffd00027;
border-radius: 4px;
min-width: 200px;
min-height: 200px;
display: block;
outline: 0px solid transparent;
}
div[data-youtube-video] > iframe {
cursor: move;
aspect-ratio: 16 / 9;
width: 100%;
}
.ProseMirror-selectednode iframe {
transition: outline 0.15s;
outline: 6px solid #fbbf24;
}
@media only screen and (max-width: 480px) {
div[data-youtube-video] > iframe {
max-height: 50px;
}
}
@media only screen and (max-width: 720px) {
div[data-youtube-video] > iframe {
max-height: 100px;
}
}
/* CSS for bold coloring and highlighting issue*/
span[style] > strong {
color: inherit;
}
mark[style] > strong {
color: inherit;
}
Update your globals.css with Novel css vars
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
...
--novel-highlight-default: #ffffff;
--novel-highlight-purple: #f6f3f8;
--novel-highlight-red: #fdebeb;
--novel-highlight-yellow: #fbf4a2;
--novel-highlight-blue: #c1ecf9;
--novel-highlight-green: #acf79f;
--novel-highlight-orange: #faebdd;
--novel-highlight-pink: #faf1f5;
--novel-highlight-gray: #f1f1ef;
}
.dark {
....
--novel-highlight-default: #000000;
--novel-highlight-purple: #3f2c4b;
--novel-highlight-red: #5c1a1a;
--novel-highlight-yellow: #5c4b1a;
--novel-highlight-blue: #1a3d5c;
--novel-highlight-green: #1a5c20;
--novel-highlight-orange: #5c3a1a;
--novel-highlight-pink: #5c1a3a;
--novel-highlight-gray: #3a3a3a;
}
}
require("@tailwindcss/typography")
for the prose stylingUsage within Dialogs
Novel has been designed to work automatically within Radix Dialogs, namely by looking for the closest parent attribute [role="dialog"]
. If you’re using a different implementation for popups and dialogs, ensure you add this attribute above the editor so the drag handle calculates the correct position.