A Notion-style editable content block with a hover gutter, a drag grip that opens a block-type menu, and contentEditable text that re-renders as headings, lists, to-dos, quotes or code.
npx shadcn@latest add @paragon/doc-blockAlso installs: dropdown-menu
"use client";
import * as React from "react";
import {
GripVertical,
Plus,
Type,
Heading1,
Heading2,
List,
ListChecks,
Quote,
Code,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type DocBlockType =
| "text"
| "heading1"
| "heading2"
| "bullet"
| "todo"
| "quote"
| "code";
export interface DocBlockTypeOption {
type: DocBlockType;
label: string;
icon: React.ElementType;
hint?: string;
}
export const docBlockTypes: DocBlockTypeOption[] = [
{ type: "text", label: "Text", icon: Type, hint: "Plain paragraph" },
{ type: "heading1", label: "Heading 1", icon: Heading1, hint: "Section title" },
{ type: "heading2", label: "Heading 2", icon: Heading2, hint: "Subsection" },
{ type: "bullet", label: "Bulleted list", icon: List, hint: "Simple list" },
{ type: "todo", label: "To-do", icon: ListChecks, hint: "Checkbox item" },
{ type: "quote", label: "Quote", icon: Quote, hint: "Callout quote" },
{ type: "code", label: "Code", icon: Code, hint: "Monospace block" },
];
const blockClasses: Record<DocBlockType, string> = {
text: "text-sm leading-relaxed",
heading1: "text-xl font-semibold leading-tight",
heading2: "text-base font-semibold leading-tight",
bullet: "text-sm leading-relaxed",
todo: "text-sm leading-relaxed",
quote: "text-sm leading-relaxed italic text-muted-foreground",
code: "font-mono text-[13px] leading-relaxed",
};
export interface DocBlockProps
extends Omit<React.ComponentProps<"div">, "onChange" | "content"> {
type?: DocBlockType;
content?: string;
placeholder?: string;
/** Only meaningful for `todo` blocks. */
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
onTypeChange?: (type: DocBlockType) => void;
onContentChange?: (content: string) => void;
onAddBelow?: () => void;
}
/**
* A Notion-style content block. A drag handle and an add-below button sit in a
* left gutter, appearing on row hover (opacity only — no layout shift). The
* grip opens a block-type menu that re-renders the row as a heading, list,
* to-do, quote or code line. Text is a real `contentEditable` region so
* caret, selection and typing work; empty blocks show a placeholder via the
* `:empty` pseudo-class. Todo blocks get a leading checkbox with a strike on
* completion.
*/
export function DocBlock({
type = "text",
content = "",
placeholder = "Write, or press the grip to change block type",
checked = false,
onCheckedChange,
onTypeChange,
onContentChange,
onAddBelow,
className,
...props
}: DocBlockProps) {
const editableRef = React.useRef<HTMLDivElement>(null);
// Seed the editable node once; contentEditable owns it after that so React
// never fights the caret.
React.useEffect(() => {
if (editableRef.current && editableRef.current.textContent !== content) {
editableRef.current.textContent = content;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
data-slot="doc-block"
className={cn("group/block relative flex items-start gap-1", className)}
{...props}
>
{/* Left gutter controls. */}
<div
className={cn(
"flex shrink-0 items-center gap-0.5 pt-0.5 opacity-0",
"group-hover/block:opacity-100 group-focus-within/block:opacity-100",
"transition-opacity duration-(--duration-fast) ease-(--ease-out) motion-reduce:transition-none",
)}
>
<button
type="button"
aria-label="Add block below"
onClick={onAddBelow}
className={cn(
"pressable relative flex size-6 items-center justify-center rounded-md text-muted-foreground",
"transition-colors duration-(--duration-fast) hover:bg-accent hover:text-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
"after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-1/2",
)}
>
<Plus className="size-4" aria-hidden />
</button>
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Change block type"
className={cn(
"relative flex size-6 cursor-grab items-center justify-center rounded-md text-muted-foreground",
"transition-colors duration-(--duration-fast) hover:bg-accent hover:text-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<GripVertical className="size-4" aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel>Turn into</DropdownMenuLabel>
<DropdownMenuSeparator />
{docBlockTypes.map((option) => {
const Icon = option.icon;
return (
<DropdownMenuItem
key={option.type}
onSelect={() => onTypeChange?.(option.type)}
className="gap-2.5"
>
<Icon className="size-4" aria-hidden />
<span className="flex-1">{option.label}</span>
{option.type === type && (
<span className="size-1.5 rounded-full bg-foreground" aria-hidden />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Block body. */}
<div className="flex min-w-0 flex-1 items-start gap-2 py-0.5">
{type === "bullet" && (
<span
aria-hidden
className="mt-2 size-1.5 shrink-0 rounded-full bg-foreground"
/>
)}
{type === "quote" && (
<span
aria-hidden
className="mt-0.5 self-stretch w-0.5 shrink-0 rounded-full bg-border"
/>
)}
{type === "todo" && (
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label="Toggle to-do"
onClick={() => onCheckedChange?.(!checked)}
className={cn(
"mt-1 flex size-4 shrink-0 items-center justify-center rounded-[5px] border",
"transition-[background-color,border-color] duration-(--duration-fast) ease-out",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
checked
? "border-primary bg-primary text-primary-foreground"
: "border-input",
)}
>
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className="size-3"
aria-hidden
>
<path
d="M2.25 6.4 4.9 9 9.75 3.5"
pathLength={1}
className={cn(
"[stroke-dasharray:1] [stroke-dashoffset:1]",
"transition-[stroke-dashoffset] duration-200 ease-out motion-reduce:transition-none",
checked && "[stroke-dashoffset:0]",
)}
/>
</svg>
</button>
)}
<div
ref={editableRef}
role="textbox"
aria-multiline="true"
contentEditable
suppressContentEditableWarning
data-placeholder={placeholder}
onInput={(event) =>
onContentChange?.(event.currentTarget.textContent ?? "")
}
className={cn(
"min-w-0 flex-1 rounded-sm outline-none",
"focus-visible:ring-0",
"empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]",
blockClasses[type],
type === "code" &&
"rounded-md bg-secondary px-2.5 py-1.5 text-secondary-foreground",
type === "todo" && checked && "text-muted-foreground line-through",
)}
/>
</div>
</div>
);
}