Skip to main content

Overview

Selectors are components that appear in the bubble menu when text is selected. They provide quick access to formatting options like node type, links, text styles, and colors.

Bubble Menu Structure

The bubble menu contains four main selectors:
<EditorBubble
  tippyOptions={{
    placement: 'top',
    zIndex: 10,
  }}
  className="flex w-fit max-w-[90vw] overflow-hidden rounded-md border border-muted bg-background shadow-xl"
>
  <Separator orientation="vertical" />
  <NodeSelector open={openNode} onOpenChange={setOpenNode} />
  <Separator orientation="vertical" />
  <LinkSelector open={openLink} onOpenChange={setOpenLink} />
  <Separator orientation="vertical" />
  <TextButtons />
  <Separator orientation="vertical" />
  <ColorSelector open={openColor} onOpenChange={setOpenColor} />
</EditorBubble>

NodeSelector

Changes the block type of the current node (paragraph, heading, list, etc.).

Props

open
boolean
required
Controls whether the popover is open
onOpenChange
(open: boolean) => void
required
Callback when popover state changes

Implementation

import { NodeSelector } from '@/components/editor/selectors/node-selector'
import { useState } from 'react'

export default function Editor() {
  const [openNode, setOpenNode] = useState(false)
  
  return (
    <NodeSelector 
      open={openNode} 
      onOpenChange={setOpenNode} 
    />
  )
}

Available Node Types

Plain paragraph text.
{
  name: 'Text',
  icon: TextIcon,
  command: (editor) => 
    editor?.chain().focus().clearNodes().run(),
  isActive: (editor) =>
    editor.isActive('paragraph') &&
    !editor.isActive('bulletList') &&
    !editor.isActive('orderedList')
}

TypeScript Interface

export type SelectorItem = {
  name: string
  icon: LucideIcon
  command: (editor: ReturnType<typeof useEditor>['editor']) => void
  isActive: (editor: ReturnType<typeof useEditor>['editor']) => boolean
}

interface NodeSelectorProps {
  open: boolean
  onOpenChange: (open: boolean) => void
}

LinkSelector

Adds or edits hyperlinks on selected text.

Props

open
boolean
required
Controls whether the popover is open
onOpenChange
(open: boolean) => void
required
Callback when popover state changes

Implementation

import { LinkSelector } from '@/components/editor/selectors/link-selector'
import { useState } from 'react'

export default function Editor() {
  const [openLink, setOpenLink] = useState(false)
  
  return (
    <LinkSelector 
      open={openLink} 
      onOpenChange={setOpenLink} 
    />
  )
}

Features

The URL input automatically focuses when the popover opens:
const inputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
  inputRef.current?.focus()
})
Helper functions validate and normalize URLs:
export function isValidUrl(url: string) {
  try {
    new URL(url)
    return true
  } catch (_e) {
    return false
  }
}

export function getUrlFromString(str: string) {
  if (isValidUrl(str)) return str
  try {
    if (str.includes('.') && !str.includes(' ')) {
      return new URL(`https://${str}`).toString()
    }
  } catch (_e) {
    return null
  }
}

Usage Example

<form
  onSubmit={(e) => {
    e.preventDefault()
    const input = e.currentTarget[0] as HTMLInputElement
    const url = getUrlFromString(input.value)
    if (url) {
      editor.chain().focus().setLink({ href: url }).run()
      onOpenChange(false)
    }
  }}
>
  <input
    ref={inputRef}
    type="text"
    placeholder="Paste a link"
    className="flex-1 bg-background p-1 text-sm outline-none"
    defaultValue={editor.getAttributes('link').href || ''}
  />
</form>

TextButtons

Toggles text formatting (bold, italic, underline, strikethrough, code).

Implementation

import { TextButtons } from '@/components/editor/selectors/text-buttons'

<EditorBubble>
  <TextButtons />
</EditorBubble>

Available Formats

{
  name: 'bold',
  isActive: (editor) => editor?.isActive('bold'),
  command: (editor) => editor?.chain().focus().toggleBold().run(),
  icon: BoldIcon,
}
Keyboard shortcut: Cmd/Ctrl + B

Button Rendering

<div className="flex">
  {items.map((item) => (
    <EditorBubbleItem
      key={item.name}
      onSelect={(editor) => {
        item.command(editor)
      }}
    >
      <Button size="sm" className="rounded-none" variant="ghost">
        <item.icon
          className={cn('h-4 w-4', {
            'text-blue-500': item.isActive(editor),
          })}
        />
      </Button>
    </EditorBubbleItem>
  ))}
</div>
Active buttons are highlighted in blue.

ColorSelector

Applies text color and background highlighting.

Props

open
boolean
required
Controls whether the popover is open
onOpenChange
(open: boolean) => void
required
Callback when popover state changes

Implementation

import { ColorSelector } from '@/components/editor/selectors/color-selector'
import { useState } from 'react'

export default function Editor() {
  const [openColor, setOpenColor] = useState(false)
  
  return (
    <ColorSelector 
      open={openColor} 
      onOpenChange={setOpenColor} 
    />
  )
}

Text Colors

const TEXT_COLORS: BubbleColorMenuItem[] = [
  { name: 'Default', color: 'var(--novel-black)' },
  { name: 'Purple', color: '#9333EA' },
  { name: 'Red', color: '#E00000' },
  { name: 'Yellow', color: '#EAB308' },
  { name: 'Blue', color: '#2563EB' },
  { name: 'Green', color: '#008A00' },
  { name: 'Orange', color: '#FFA500' },
  { name: 'Pink', color: '#BA4081' },
  { name: 'Gray', color: '#A8A29E' },
]

Highlight Colors

const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
  { name: 'Default', color: 'var(--novel-highlight-default)' },
  { name: 'Purple', color: 'var(--novel-highlight-purple)' },
  { name: 'Red', color: 'var(--novel-highlight-red)' },
  { name: 'Yellow', color: 'var(--novel-highlight-yellow)' },
  { name: 'Blue', color: 'var(--novel-highlight-blue)' },
  { name: 'Green', color: 'var(--novel-highlight-green)' },
  { name: 'Orange', color: 'var(--novel-highlight-orange)' },
  { name: 'Pink', color: 'var(--novel-highlight-pink)' },
  { name: 'Gray', color: 'var(--novel-highlight-gray)' },
]

Active Color Display

The trigger button shows current colors:
const activeColorItem = TEXT_COLORS.find(({ color }) =>
  editor.isActive('textStyle', { color }),
)

const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
  editor.isActive('highlight', { color }),
)

<Button size="sm" variant="ghost">
  <span
    className="rounded-sm px-1"
    style={{
      color: activeColorItem?.color,
      backgroundColor: activeHighlightItem?.color,
    }}
  >
    A
  </span>
  <ChevronDown className="h-4 w-4" />
</Button>

TypeScript Interface

export interface BubbleColorMenuItem {
  name: string
  color: string
}

interface ColorSelectorProps {
  open: boolean
  onOpenChange: (open: boolean) => void
}

Custom Selectors

Create your own selector components:
import { useEditor, EditorBubbleItem } from 'novel'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'

interface MyCustomSelectorProps {
  open: boolean
  onOpenChange: (open: boolean) => void
}

export const MyCustomSelector = ({ open, onOpenChange }: MyCustomSelectorProps) => {
  const { editor } = useEditor()
  if (!editor) return null
  
  return (
    <Popover modal={true} open={open} onOpenChange={onOpenChange}>
      <PopoverTrigger asChild>
        <Button size="sm" variant="ghost">
          My Selector
        </Button>
      </PopoverTrigger>
      <PopoverContent>
        <EditorBubbleItem
          onSelect={(editor) => {
            // Your custom command
            onOpenChange(false)
          }}
        >
          Custom Action
        </EditorBubbleItem>
      </PopoverContent>
    </Popover>
  )
}

Styling

All selectors use Tailwind CSS and shadcn/ui components:
// Button styling
<Button 
  size="sm" 
  variant="ghost" 
  className="gap-2 rounded-none border-none hover:bg-accent"
>

// Popover styling
<PopoverContent 
  sideOffset={5} 
  align="start" 
  className="w-48 p-1"
>

// Menu item styling
<EditorBubbleItem
  className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent"
>

Next Steps

Editor Overview

Learn about editor architecture

Extensions

Explore TipTap extensions

Build docs developers (and LLMs) love