Menu

Building a Rich Text Format Toolbar with MUI Toggle Button

As a front-end developer, creating intuitive text formatting tools is a common requirement in applications that deal with content creation. Material UI's ToggleButton component provides an excellent foundation for building text formatting toolbars similar to those you'd find in document editors or content management systems.

In this article, I'll walk you through building a text formatting toolbar focused on the bold formatting feature using MUI's ToggleButton. We'll start with the basics and progressively enhance our implementation to cover advanced use cases, customizations, and best practices.

What You'll Learn

By the end of this tutorial, you'll know how to:

  • Implement a text formatting toolbar using MUI's ToggleButton
  • Handle state management for text formatting options
  • Apply formatting to selected text in various contexts
  • Customize the appearance and behavior of the toolbar
  • Integrate the toolbar with content editing components
  • Optimize performance and ensure accessibility

Understanding MUI ToggleButton Component

Before diving into implementation, let's understand what makes the ToggleButton component perfect for text formatting tools.

What is a ToggleButton?

The ToggleButton component is a button that can be toggled on or off, making it ideal for binary states like enabling/disabling text formatting options. Unlike regular buttons that trigger actions, toggle buttons represent states. This aligns perfectly with formatting controls like bold, italic, or underline, which are either active or inactive for selected text.

MUI's ToggleButton component extends the basic toggle button pattern with Material Design aesthetics and additional features for group management, which is perfect for formatting toolbars where multiple options need to work together.

Key Features of MUI ToggleButton

The ToggleButton component offers several features that make it suitable for text formatting:

  1. Toggle State Management: It manages selected/unselected states internally, simplifying your code.
  2. Visual Feedback: It provides clear visual cues for the current state.
  3. Grouping Capability: ToggleButtonGroup allows for exclusive or non-exclusive selection modes.
  4. Accessibility: Built-in keyboard navigation and ARIA attributes ensure good accessibility.
  5. Customization: Extensive styling options through theme overrides, the sx prop, or styled components.

MUI ToggleButton Deep Dive

Let's explore the ToggleButton component in detail to understand all its capabilities and configuration options.

Component Props

The ToggleButton component accepts several props that control its behavior and appearance.

PropTypeDefaultDescription
valueanyrequiredThe value to associate with the button when selected
selectedboolfalseIf true, the button is rendered in a selected state
onChangefunc-Callback fired when the state changes
disabledboolfalseIf true, the component is disabled
disableFocusRippleboolfalseIf true, the keyboard focus ripple is disabled
disableRippleboolfalseIf true, the ripple effect is disabled
fullWidthboolfalseIf true, the button will take up the full width of its container
size'small' | 'medium' | 'large''medium'The size of the component
color'standard' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning''standard'The color of the component

When using ToggleButton for text formatting, the value prop is particularly important as it identifies which formatting option the button represents (e.g., 'bold', 'italic'). The selected prop indicates whether that formatting is currently active.

ToggleButtonGroup

For a formatting toolbar, we'll typically use the ToggleButtonGroup component to manage multiple ToggleButtons. This component has its own set of important props:

PropTypeDefaultDescription
valueany-The currently selected value(s) within the group
exclusiveboolfalseIf true, only allow one button to be selected at a time
onChangefunc-Callback fired when the value changes
orientation'horizontal' | 'vertical''horizontal'The component orientation
size'small' | 'medium' | 'large''medium'The size of the component
color'standard' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning''standard'The color of the component

For text formatting, we typically set exclusive to false since multiple formatting options (like bold and italic) can be applied simultaneously to the same text.

Controlled vs Uncontrolled Usage

ToggleButton and ToggleButtonGroup can be used in both controlled and uncontrolled modes:

Controlled Mode: In controlled mode, you manage the state externally and pass it to the component through props. This gives you more control and allows for complex interactions.


import React, { useState } from 'react';
import { ToggleButton } from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';

function ControlledBoldButton() {
  const [selected, setSelected] = useState(false);

  const handleChange = () => {
    setSelected(!selected);
  };

  return (
    <ToggleButton
      value="bold"
      selected={selected}
      onChange={handleChange}
    >
      <FormatBoldIcon />
    </ToggleButton>
  );
}

Uncontrolled Mode: In uncontrolled mode, the component manages its own state internally.


import React from 'react';
import { ToggleButton } from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';

function UncontrolledBoldButton() {
  return (
    <ToggleButton
      value="bold"
      onChange={() => {
        // Handle the change event if needed
        console.log('Bold toggled');
      }}
    >
      <FormatBoldIcon />
    </ToggleButton>
  );
}

For a text formatting toolbar, controlled mode is generally preferred because you'll need to synchronize the button states with the actual formatting of the text.

Customization Options

ToggleButton offers several customization options:

  1. Styling with the sx prop:

<ToggleButton
  value="bold"
  sx={{
    borderRadius: '8px',
    '&.Mui-selected': {
      backgroundColor: 'primary.dark',
      color: 'white',
      '&:hover': {
        backgroundColor: 'primary.dark',
        opacity: 0.9,
      },
    },
  }}
>
  <FormatBoldIcon />
</ToggleButton>
  1. Theme Overrides:

import { createTheme, ThemeProvider } from '@mui/material/styles';

const theme = createTheme({
  components: {
    MuiToggleButton: {
      styleOverrides: {
        root: {
          borderRadius: 8,
          '&.Mui-selected': {
            backgroundColor: '#1976d2',
            color: '#fff',
            '&:hover': {
              backgroundColor: '#1565c0',
            },
          },
        },
      },
    },
  },
});

function App() {
  return (
    <ThemeProvider theme={theme}>
      {/* Your components */}
    </ThemeProvider>
  );
}
  1. Styled Components API:

import { styled } from '@mui/material/styles';
import ToggleButton from '@mui/material/ToggleButton';

const StyledToggleButton = styled(ToggleButton)(({ theme }) => ({
  borderRadius: 8,
  '&.Mui-selected': {
    backgroundColor: theme.palette.primary.main,
    color: theme.palette.primary.contrastText,
    '&:hover': {
      backgroundColor: theme.palette.primary.dark,
    },
  },
}));

Accessibility Considerations

ToggleButton comes with good accessibility features out of the box, but there are some best practices to follow:

  1. Provide meaningful labels: Use aria-label for icon-only buttons.
  2. Group related buttons: Use ToggleButtonGroup with aria-label to indicate the purpose of the group.
  3. Maintain focus management: Ensure keyboard navigation works correctly.

<ToggleButtonGroup
  aria-label="text formatting options"
>
  <ToggleButton 
    value="bold" 
    aria-label="bold text"
  >
    <FormatBoldIcon />
  </ToggleButton>
  {/* Other formatting buttons */}
</ToggleButtonGroup>

Building a Text Formatting Toolbar

Now let's build our text formatting toolbar step by step, focusing on implementing the bold formatting feature.

Setting Up the Project

First, let's set up a new React project and install the necessary dependencies.


# Create a new React project
npx create-react-app text-formatting-toolbar

# Navigate to the project directory
cd text-formatting-toolbar

# Install Material UI and required dependencies
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled

Creating the Basic Toolbar Structure

Let's start by creating a basic toolbar with a bold formatting button.


import React, { useState } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper 
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';

function TextFormatToolbar() {
  const [formats, setFormats] = useState([]);

  const handleFormat = (event, newFormats) => {
    setFormats(newFormats);
  };

  return (
    <Paper elevation={2} sx={{ p: 1, mb: 2 }}>
      <ToggleButtonGroup
        value={formats}
        onChange={handleFormat}
        aria-label="text formatting"
      >
        <ToggleButton value="bold" aria-label="bold">
          <FormatBoldIcon />
        </ToggleButton>
      </ToggleButtonGroup>
    </Paper>
  );
}

export default TextFormatToolbar;

In this basic implementation, we've:

  1. Created a ToggleButtonGroup to hold our formatting options
  2. Added a single ToggleButton for bold formatting
  3. Set up state management to track which formatting options are active
  4. Wrapped the toolbar in a Paper component for visual elevation

The formats state is an array that keeps track of which formatting options are currently active. When the bold button is clicked, the handleFormat function updates this state.

Adding a Text Editor Area

To make our toolbar functional, we need a text area to apply the formatting to. Let's add a simple text area below our toolbar.


import React, { useState, useRef } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  TextField,
  Box
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';

function TextEditor() {
  const [formats, setFormats] = useState([]);
  const [text, setText] = useState('Select some text and click the bold button to format it.');
  const textFieldRef = useRef(null);

  const handleFormat = (event, newFormats) => {
    setFormats(newFormats);
    
    // Apply formatting to selected text
    if (textFieldRef.current) {
      const textField = textFieldRef.current.querySelector('textarea');
      if (textField) {
        const start = textField.selectionStart;
        const end = textField.selectionEnd;
        
        // Only proceed if there's a text selection
        if (start !== end) {
          const selectedText = text.substring(start, end);
          let newText = text;
          
          // Check if bold was toggled on or off
          const boldAdded = newFormats.includes('bold') && !formats.includes('bold');
          const boldRemoved = !newFormats.includes('bold') && formats.includes('bold');
          
          if (boldAdded) {
            // Add bold by wrapping with asterisks (in a real app, you might use HTML)
            newText = text.substring(0, start) + '**' + selectedText + '**' + text.substring(end);
            setText(newText);
          } else if (boldRemoved) {
            // This is simplified and would need more logic in a real app
            // to handle removing formatting properly
            setText(text);
          }
        }
      }
    }
  };

  return (
    <Box>
      <Paper elevation={2} sx={{ p: 1, mb: 2 }}>
        <ToggleButtonGroup
          value={formats}
          onChange={handleFormat}
          aria-label="text formatting"
        >
          <ToggleButton value="bold" aria-label="bold">
            <FormatBoldIcon />
          </ToggleButton>
        </ToggleButtonGroup>
      </Paper>
      
      <TextField
        ref={textFieldRef}
        fullWidth
        multiline
        rows={4}
        value={text}
        onChange={(e) => setText(e.target.value)}
        variant="outlined"
      />
    </Box>
  );
}

export default TextEditor;

This implementation adds:

  1. A TextField component for editing text
  2. State management for the text content
  3. A ref to access the text field's DOM element
  4. Logic to apply bold formatting to selected text

In this simple example, we're representing bold text by wrapping it with asterisks (**). In a real-world application, you might use HTML tags, rich text formats, or a dedicated rich text editor library.

Implementing Proper Text Formatting

The previous example is very simplistic. Let's improve it by implementing a more realistic rich text editing approach using the document.execCommand API, which works with contentEditable elements.


import React, { useState, useRef, useEffect } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  Box 
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';

function RichTextEditor() {
  const [formats, setFormats] = useState([]);
  const editorRef = useRef(null);

  // Check current formatting when selection changes
  const checkFormatting = () => {
    const isBold = document.queryCommandState('bold');
    setFormats(isBold ? ['bold'] : []);
  };

  useEffect(() => {
    const editor = editorRef.current;
    if (editor) {
      editor.addEventListener('mouseup', checkFormatting);
      editor.addEventListener('keyup', checkFormatting);
      
      return () => {
        editor.removeEventListener('mouseup', checkFormatting);
        editor.removeEventListener('keyup', checkFormatting);
      };
    }
  }, []);

  const handleFormat = (event, newFormats) => {
    // Focus the editor if it's not already focused
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    // Apply bold formatting
    document.execCommand('bold', false, null);
    
    // Update state to reflect current formatting
    checkFormatting();
  };

  return (
    <Box>
      <Paper elevation={2} sx={{ p: 1, mb: 2 }}>
        <ToggleButtonGroup
          value={formats}
          onChange={handleFormat}
          aria-label="text formatting"
        >
          <ToggleButton value="bold" aria-label="bold">
            <FormatBoldIcon />
          </ToggleButton>
        </ToggleButtonGroup>
      </Paper>
      
      <Paper
        ref={editorRef}
        contentEditable
        sx={{
          p: 2,
          minHeight: '200px',
          border: '1px solid #ccc',
          borderRadius: 1,
          outline: 'none',
          '&:focus': {
            borderColor: 'primary.main',
          },
        }}
        suppressContentEditableWarning
      >
        Select some text and click the bold button to format it.
      </Paper>
    </Box>
  );
}

export default RichTextEditor;

This implementation:

  1. Uses a contentEditable element instead of a TextField
  2. Applies real bold formatting using document.execCommand('bold')
  3. Detects current formatting using document.queryCommandState('bold')
  4. Updates the ToggleButton state to reflect the formatting of the current selection
  5. Adds event listeners to check formatting when the selection changes

Note that while document.execCommand is widely supported, it's considered deprecated. For production applications, consider using a dedicated rich text editor library like Draft.js, Slate, or Quill.

Enhancing the Toolbar with More Formatting Options

Let's expand our toolbar to include more formatting options like italic and underline.


import React, { useState, useRef, useEffect } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  Box,
  Divider
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined';

function EnhancedRichTextEditor() {
  const [formats, setFormats] = useState([]);
  const editorRef = useRef(null);

  // Check current formatting when selection changes
  const checkFormatting = () => {
    const newFormats = [];
    if (document.queryCommandState('bold')) newFormats.push('bold');
    if (document.queryCommandState('italic')) newFormats.push('italic');
    if (document.queryCommandState('underline')) newFormats.push('underline');
    setFormats(newFormats);
  };

  useEffect(() => {
    const editor = editorRef.current;
    if (editor) {
      editor.addEventListener('mouseup', checkFormatting);
      editor.addEventListener('keyup', checkFormatting);
      
      return () => {
        editor.removeEventListener('mouseup', checkFormatting);
        editor.removeEventListener('keyup', checkFormatting);
      };
    }
  }, []);

  const handleFormat = (event, newFormats) => {
    // Focus the editor if it's not already focused
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    // Determine which format was changed
    const changedFormat = event.currentTarget.value;
    
    // Apply the appropriate formatting
    document.execCommand(changedFormat, false, null);
    
    // Update state to reflect current formatting
    checkFormatting();
  };

  return (
    <Box>
      <Paper elevation={2} sx={{ p: 1, mb: 2, display: 'flex', alignItems: 'center' }}>
        <ToggleButtonGroup
          value={formats}
          onChange={handleFormat}
          aria-label="text formatting"
        >
          <ToggleButton value="bold" aria-label="bold">
            <FormatBoldIcon />
          </ToggleButton>
          <ToggleButton value="italic" aria-label="italic">
            <FormatItalicIcon />
          </ToggleButton>
          <ToggleButton value="underline" aria-label="underline">
            <FormatUnderlinedIcon />
          </ToggleButton>
        </ToggleButtonGroup>
      </Paper>
      
      <Paper
        ref={editorRef}
        contentEditable
        sx={{
          p: 2,
          minHeight: '200px',
          border: '1px solid #ccc',
          borderRadius: 1,
          outline: 'none',
          '&:focus': {
            borderColor: 'primary.main',
          },
        }}
        suppressContentEditableWarning
      >
        Select some text and use the toolbar buttons to format it. You can make text <b>bold</b>, <i>italic</i>, or <u>underlined</u>.
      </Paper>
    </Box>
  );
}

export default EnhancedRichTextEditor;

This enhanced version:

  1. Adds buttons for italic and underline formatting
  2. Checks for all three formatting types when updating the state
  3. Determines which format was changed and applies only that formatting
  4. Shows pre-formatted text in the editor to demonstrate the formatting options

Creating a Complete Text Formatting Toolbar

Now, let's create a more complete text formatting toolbar with additional features:


import React, { useState, useRef, useEffect } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  Box,
  Divider,
  Select,
  MenuItem,
  Button,
  IconButton,
  Tooltip
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
import FormatAlignJustifyIcon from '@mui/icons-material/FormatAlignJustify';
import FormatColorTextIcon from '@mui/icons-material/FormatColorText';
import FormatColorFillIcon from '@mui/icons-material/FormatColorFill';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';

function CompleteTextEditor() {
  const [formats, setFormats] = useState([]);
  const [alignment, setAlignment] = useState('left');
  const [fontSize, setFontSize] = useState('3');
  const editorRef = useRef(null);

  // Check current formatting when selection changes
  const checkFormatting = () => {
    const newFormats = [];
    if (document.queryCommandState('bold')) newFormats.push('bold');
    if (document.queryCommandState('italic')) newFormats.push('italic');
    if (document.queryCommandState('underline')) newFormats.push('underline');
    setFormats(newFormats);
    
    // Check alignment
    if (document.queryCommandState('justifyLeft')) setAlignment('left');
    if (document.queryCommandState('justifyCenter')) setAlignment('center');
    if (document.queryCommandState('justifyRight')) setAlignment('right');
    if (document.queryCommandState('justifyFull')) setAlignment('justify');
  };

  useEffect(() => {
    const editor = editorRef.current;
    if (editor) {
      editor.addEventListener('mouseup', checkFormatting);
      editor.addEventListener('keyup', checkFormatting);
      
      return () => {
        editor.removeEventListener('mouseup', checkFormatting);
        editor.removeEventListener('keyup', checkFormatting);
      };
    }
  }, []);

  const handleFormat = (event, newFormats) => {
    // Focus the editor if it's not already focused
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    // Determine which format was changed
    const changedFormat = event.currentTarget.value;
    
    // Apply the appropriate formatting
    document.execCommand(changedFormat, false, null);
    
    // Update state to reflect current formatting
    checkFormatting();
  };

  const handleAlignment = (event, newAlignment) => {
    if (newAlignment !== null) {
      // Focus the editor
      if (document.activeElement !== editorRef.current) {
        editorRef.current.focus();
      }
      
      // Apply alignment
      switch (newAlignment) {
        case 'left':
          document.execCommand('justifyLeft', false, null);
          break;
        case 'center':
          document.execCommand('justifyCenter', false, null);
          break;
        case 'right':
          document.execCommand('justifyRight', false, null);
          break;
        case 'justify':
          document.execCommand('justifyFull', false, null);
          break;
        default:
          break;
      }
      
      setAlignment(newAlignment);
    }
  };

  const handleFontSize = (event) => {
    const newSize = event.target.value;
    setFontSize(newSize);
    
    // Focus the editor
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    // Apply font size
    document.execCommand('fontSize', false, newSize);
  };

  const handleUndo = () => {
    document.execCommand('undo', false, null);
  };

  const handleRedo = () => {
    document.execCommand('redo', false, null);
  };

  return (
    <Box>
      <Paper elevation={2} sx={{ p: 1, mb: 2 }}>
        <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
          <ToggleButtonGroup
            value={formats}
            onChange={handleFormat}
            aria-label="text formatting"
            size="small"
          >
            <ToggleButton value="bold" aria-label="bold">
              <FormatBoldIcon />
            </ToggleButton>
            <ToggleButton value="italic" aria-label="italic">
              <FormatItalicIcon />
            </ToggleButton>
            <ToggleButton value="underline" aria-label="underline">
              <FormatUnderlinedIcon />
            </ToggleButton>
          </ToggleButtonGroup>
          
          <Divider orientation="vertical" flexItem />
          
          <ToggleButtonGroup
            value={alignment}
            exclusive
            onChange={handleAlignment}
            aria-label="text alignment"
            size="small"
          >
            <ToggleButton value="left" aria-label="align left">
              <FormatAlignLeftIcon />
            </ToggleButton>
            <ToggleButton value="center" aria-label="align center">
              <FormatAlignCenterIcon />
            </ToggleButton>
            <ToggleButton value="right" aria-label="align right">
              <FormatAlignRightIcon />
            </ToggleButton>
            <ToggleButton value="justify" aria-label="align justify">
              <FormatAlignJustifyIcon />
            </ToggleButton>
          </ToggleButtonGroup>
          
          <Divider orientation="vertical" flexItem />
          
          <Select
            value={fontSize}
            onChange={handleFontSize}
            size="small"
            sx={{ minWidth: 80 }}
          >
            <MenuItem value="1">Small</MenuItem>
            <MenuItem value="3">Normal</MenuItem>
            <MenuItem value="5">Large</MenuItem>
            <MenuItem value="7">Huge</MenuItem>
          </Select>
          
          <Divider orientation="vertical" flexItem />
          
          <Tooltip title="Text color">
            <IconButton size="small">
              <FormatColorTextIcon />
            </IconButton>
          </Tooltip>
          
          <Tooltip title="Background color">
            <IconButton size="small">
              <FormatColorFillIcon />
            </IconButton>
          </Tooltip>
          
          <Box sx={{ flexGrow: 1 }} />
          
          <Tooltip title="Undo">
            <IconButton onClick={handleUndo} size="small">
              <UndoIcon />
            </IconButton>
          </Tooltip>
          
          <Tooltip title="Redo">
            <IconButton onClick={handleRedo} size="small">
              <RedoIcon />
            </IconButton>
          </Tooltip>
        </Box>
      </Paper>
      
      <Paper
        ref={editorRef}
        contentEditable
        sx={{
          p: 2,
          minHeight: '300px',
          border: '1px solid #ccc',
          borderRadius: 1,
          outline: 'none',
          '&:focus': {
            borderColor: 'primary.main',
          },
        }}
        suppressContentEditableWarning
      >
        <h2>Rich Text Editor</h2>
        <p>This is a complete text formatting toolbar example. Select some text and use the toolbar buttons to format it.</p>
        <p>You can make text <b>bold</b>, <i>italic</i>, or <u>underlined</u>.</p>
        <p>You can also change text alignment and font size.</p>
      </Paper>
    </Box>
  );
}

export default CompleteTextEditor;

This comprehensive text editor includes:

  1. Text formatting options (bold, italic, underline)
  2. Text alignment controls (left, center, right, justify)
  3. Font size selection
  4. Placeholder buttons for text and background color
  5. Undo and redo functionality
  6. Tooltips for better usability
  7. Responsive layout with dividers between groups

Note that some features like color pickers would need additional implementation in a real-world application.

Optimizing Performance

For better performance, let's optimize our text editor by:

  1. Debouncing the formatting checks
  2. Memoizing components
  3. Using callback refs to avoid unnecessary re-renders

import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  Box,
  Divider
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined';
import debounce from 'lodash/debounce';

function OptimizedTextEditor() {
  const [formats, setFormats] = useState([]);
  const [editor, setEditor] = useState(null);

  // Create a debounced version of the checkFormatting function
  const debouncedCheckFormatting = useMemo(
    () =>
      debounce(() => {
        const newFormats = [];
        if (document.queryCommandState('bold')) newFormats.push('bold');
        if (document.queryCommandState('italic')) newFormats.push('italic');
        if (document.queryCommandState('underline')) newFormats.push('underline');
        setFormats(newFormats);
      }, 100),
    []
  );

  // Callback ref for the editor
  const editorRef = useCallback(node => {
    if (node !== null) {
      setEditor(node);
    }
  }, []);

  useEffect(() => {
    if (editor) {
      editor.addEventListener('mouseup', debouncedCheckFormatting);
      editor.addEventListener('keyup', debouncedCheckFormatting);
      
      return () => {
        editor.removeEventListener('mouseup', debouncedCheckFormatting);
        editor.removeEventListener('keyup', debouncedCheckFormatting);
        debouncedCheckFormatting.cancel();
      };
    }
  }, [editor, debouncedCheckFormatting]);

  const handleFormat = useCallback((event, newFormats) => {
    if (editor && document.activeElement !== editor) {
      editor.focus();
    }
    
    const changedFormat = event.currentTarget.value;
    document.execCommand(changedFormat, false, null);
    
    debouncedCheckFormatting();
  }, [editor, debouncedCheckFormatting]);

  // Memoized toolbar component
  const Toolbar = useMemo(() => (
    <Paper elevation={2} sx={{ p: 1, mb: 2 }}>
      <ToggleButtonGroup
        value={formats}
        onChange={handleFormat}
        aria-label="text formatting"
      >
        <ToggleButton value="bold" aria-label="bold">
          <FormatBoldIcon />
        </ToggleButton>
        <ToggleButton value="italic" aria-label="italic">
          <FormatItalicIcon />
        </ToggleButton>
        <ToggleButton value="underline" aria-label="underline">
          <FormatUnderlinedIcon />
        </ToggleButton>
      </ToggleButtonGroup>
    </Paper>
  ), [formats, handleFormat]);

  return (
    <Box>
      {Toolbar}
      
      <Paper
        ref={editorRef}
        contentEditable
        sx={{
          p: 2,
          minHeight: '200px',
          border: '1px solid #ccc',
          borderRadius: 1,
          outline: 'none',
          '&:focus': {
            borderColor: 'primary.main',
          },
        }}
        suppressContentEditableWarning
      >
        This is a performance-optimized text editor. Select some text and use the toolbar to format it.
      </Paper>
    </Box>
  );
}

export default OptimizedTextEditor;

These optimizations help prevent unnecessary re-renders and improve responsiveness:

  1. Debouncing the formatting check to avoid excessive updates
  2. Using callback refs instead of useRef with useEffect
  3. Memoizing the toolbar component to prevent re-renders when unrelated state changes
  4. Using useCallback for event handlers

Note that you'll need to install lodash for the debounce function:


npm install lodash

Integration with Form Libraries

In real-world applications, you might need to integrate your rich text editor with form libraries like Formik or React Hook Form. Here's an example using React Hook Form:


import React, { useState, useRef, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  Box,
  Button
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined';

function FormIntegratedEditor() {
  const [formats, setFormats] = useState([]);
  const editorRef = useRef(null);
  
  const { control, handleSubmit } = useForm({
    defaultValues: {
      content: '<p>This is the default content.</p>'
    }
  });

  const onSubmit = data => {
    console.log('Form submitted with content:', data.content);
    alert('Form submitted! Check console for content.');
  };

  // Check current formatting when selection changes
  const checkFormatting = () => {
    const newFormats = [];
    if (document.queryCommandState('bold')) newFormats.push('bold');
    if (document.queryCommandState('italic')) newFormats.push('italic');
    if (document.queryCommandState('underline')) newFormats.push('underline');
    setFormats(newFormats);
  };

  useEffect(() => {
    const editor = editorRef.current;
    if (editor) {
      editor.addEventListener('mouseup', checkFormatting);
      editor.addEventListener('keyup', checkFormatting);
      
      return () => {
        editor.removeEventListener('mouseup', checkFormatting);
        editor.removeEventListener('keyup', checkFormatting);
      };
    }
  }, []);

  const handleFormat = (event, newFormats) => {
    // Focus the editor if it's not already focused
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    // Determine which format was changed
    const changedFormat = event.currentTarget.value;
    
    // Apply the appropriate formatting
    document.execCommand(changedFormat, false, null);
    
    // Update state to reflect current formatting
    checkFormatting();
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Box>
        <Paper elevation={2} sx={{ p: 1, mb: 2 }}>
          <ToggleButtonGroup
            value={formats}
            onChange={handleFormat}
            aria-label="text formatting"
          >
            <ToggleButton value="bold" aria-label="bold">
              <FormatBoldIcon />
            </ToggleButton>
            <ToggleButton value="italic" aria-label="italic">
              <FormatItalicIcon />
            </ToggleButton>
            <ToggleButton value="underline" aria-label="underline">
              <FormatUnderlinedIcon />
            </ToggleButton>
          </ToggleButtonGroup>
        </Paper>
        
        <Controller
          name="content"
          control={control}
          render={({ field }) => (
            <Paper
              {...field}
              ref={(node) => {
                editorRef.current = node;
                if (typeof field.ref === 'function') {
                  field.ref(node);
                }
              }}
              contentEditable
              dangerouslySetInnerHTML={{ __html: field.value }}
              onBlur={() => {
                field.onChange(editorRef.current.innerHTML);
              }}
              sx={{
                p: 2,
                minHeight: '200px',
                border: '1px solid #ccc',
                borderRadius: 1,
                outline: 'none',
                '&:focus': {
                  borderColor: 'primary.main',
                },
              }}
              suppressContentEditableWarning
            />
          )}
        />
        
        <Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
          <Button type="submit" variant="contained" color="primary">
            Submit
          </Button>
        </Box>
      </Box>
    </form>
  );
}

export default FormIntegratedEditor;

This implementation:

  1. Integrates with React Hook Form using the Controller component
  2. Syncs the contentEditable div's HTML with the form state
  3. Handles form submission with the formatted content
  4. Preserves all the formatting functionality from previous examples

You'll need to install React Hook Form:


npm install react-hook-form

Best Practices and Common Issues

When building text formatting toolbars with MUI ToggleButton, keep these best practices in mind:

Best Practices

  1. Maintain State Synchronization: Always keep the toggle button state in sync with the actual formatting of the text. Check formatting when selection changes.

  2. Provide Visual Feedback: Use clear icons and consistent visual cues to indicate which formatting options are active.

  3. Handle Focus Management: Ensure the editor is focused when formatting is applied, but preserve the text selection.

  4. Optimize Performance: Debounce frequent operations like checking formatting on selection changes.

  5. Support Keyboard Shortcuts: Implement common keyboard shortcuts (Ctrl+B for bold, etc.) for better usability.

  6. Ensure Accessibility: Use proper ARIA labels and ensure keyboard navigation works correctly.

  7. Consider Mobile Usage: Make buttons large enough for touch interaction and consider responsive layouts for smaller screens.

Common Issues and Solutions

  1. Issue: Toggle buttons don't reflect the current formatting. Solution: Implement selection change listeners and update button states accordingly.

// Listen for both selection changes and cursor movement
document.addEventListener('selectionchange', checkFormatting);
editorRef.current.addEventListener('keyup', checkFormatting);
editorRef.current.addEventListener('mouseup', checkFormatting);
  1. Issue: Formatting is lost when submitting forms. Solution: Capture HTML content before submission and store it properly.

// With React Hook Form
<Controller
  name="content"
  control={control}
  render={({ field }) => (
    <div
      contentEditable
      dangerouslySetInnerHTML={{ __html: field.value }}
      onBlur={() => field.onChange(editorRef.current.innerHTML)}
      ref={editorRef}
    />
  )}
/>
  1. Issue: Inconsistent formatting behavior across browsers. Solution: Consider using a dedicated rich text editor library for production applications.

// Example integration with Draft.js or other libraries
import { Editor, EditorState, RichUtils } from 'draft-js';

// Handle bold formatting
const handleBoldClick = () => {
  setEditorState(RichUtils.toggleInlineStyle(editorState, 'BOLD'));
};
  1. Issue: Text selection is lost when clicking formatting buttons. Solution: Save and restore selection when applying formatting.

// Save current selection
const saveSelection = () => {
  const sel = window.getSelection();
  if (sel.getRangeAt && sel.rangeCount) {
    return sel.getRangeAt(0);
  }
  return null;
};

// Restore saved selection
const restoreSelection = (range) => {
  if (range) {
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  }
};

// Use when applying formatting
const handleFormat = () => {
  const savedRange = saveSelection();
  document.execCommand('bold', false, null);
  restoreSelection(savedRange);
};

Advanced Capabilities

Let's explore some advanced capabilities you can add to your text formatting toolbar:

Custom Formatting Options

You can extend the toolbar with custom formatting options like text highlight or strikethrough:


import React, { useState, useRef, useEffect } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  Box 
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import StrikethroughSIcon from '@mui/icons-material/StrikethroughS';
import HighlightIcon from '@mui/icons-material/Highlight';

function AdvancedFormatting() {
  const [formats, setFormats] = useState([]);
  const editorRef = useRef(null);

  // Check current formatting
  const checkFormatting = () => {
    const newFormats = [];
    if (document.queryCommandState('bold')) newFormats.push('bold');
    if (document.queryCommandState('strikeThrough')) newFormats.push('strikethrough');
    
    // Custom check for highlight (more complex as it's not a standard command)
    const selection = window.getSelection();
    if (selection.rangeCount > 0) {
      const range = selection.getRangeAt(0);
      const span = document.createElement('span');
      range.surroundContents(span);
      const hasHighlight = window.getComputedStyle(span).backgroundColor === 'rgb(255, 255, 0)';
      if (hasHighlight) newFormats.push('highlight');
      span.outerHTML = span.innerHTML; // Remove the span we added
    }
    
    setFormats(newFormats);
  };

  useEffect(() => {
    const editor = editorRef.current;
    if (editor) {
      editor.addEventListener('mouseup', checkFormatting);
      editor.addEventListener('keyup', checkFormatting);
      
      return () => {
        editor.removeEventListener('mouseup', checkFormatting);
        editor.removeEventListener('keyup', checkFormatting);
      };
    }
  }, []);

  const handleFormat = (event, newFormats) => {
    // Focus the editor
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    // Determine which format was changed
    const changedFormat = event.currentTarget.value;
    
    // Apply the appropriate formatting
    switch (changedFormat) {
      case 'bold':
        document.execCommand('bold', false, null);
        break;
      case 'strikethrough':
        document.execCommand('strikeThrough', false, null);
        break;
      case 'highlight':
        // Custom command for highlight
        document.execCommand('backColor', false, 'yellow');
        break;
      default:
        break;
    }
    
    // Update state to reflect current formatting
    checkFormatting();
  };

  return (
    <Box>
      <Paper elevation={2} sx={{ p: 1, mb: 2 }}>
        <ToggleButtonGroup
          value={formats}
          onChange={handleFormat}
          aria-label="text formatting"
        >
          <ToggleButton value="bold" aria-label="bold">
            <FormatBoldIcon />
          </ToggleButton>
          <ToggleButton value="strikethrough" aria-label="strikethrough">
            <StrikethroughSIcon />
          </ToggleButton>
          <ToggleButton value="highlight" aria-label="highlight">
            <HighlightIcon />
          </ToggleButton>
        </ToggleButtonGroup>
      </Paper>
      
      <Paper
        ref={editorRef}
        contentEditable
        sx={{
          p: 2,
          minHeight: '200px',
          border: '1px solid #ccc',
          borderRadius: 1,
          outline: 'none',
          '&:focus': {
            borderColor: 'primary.main',
          },
        }}
        suppressContentEditableWarning
      >
        Try these advanced formatting options: <b>bold</b>, <strike>strikethrough</strike>, or <span style={{ backgroundColor: 'yellow' }}>highlight</span>.
      </Paper>
    </Box>
  );
}

export default AdvancedFormatting;

Nested Formatting Options

You can also implement nested formatting options using MUI's Menu component:


import React, { useState, useRef, useEffect } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  Box,
  Menu,
  MenuItem,
  Button
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import TextFormatIcon from '@mui/icons-material/TextFormat';

function NestedFormatting() {
  const [formats, setFormats] = useState([]);
  const [fontMenuAnchor, setFontMenuAnchor] = useState(null);
  const [currentFont, setCurrentFont] = useState('Arial');
  const editorRef = useRef(null);

  const fonts = ['Arial', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana'];

  const checkFormatting = () => {
    const newFormats = [];
    if (document.queryCommandState('bold')) newFormats.push('bold');
    setFormats(newFormats);
    
    // This is a simplified approach - detecting the actual font is more complex
    // and would require analyzing the computed style of the selected text
  };

  useEffect(() => {
    const editor = editorRef.current;
    if (editor) {
      editor.addEventListener('mouseup', checkFormatting);
      editor.addEventListener('keyup', checkFormatting);
      
      return () => {
        editor.removeEventListener('mouseup', checkFormatting);
        editor.removeEventListener('keyup', checkFormatting);
      };
    }
  }, []);

  const handleFormat = (event, newFormats) => {
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    const changedFormat = event.currentTarget.value;
    document.execCommand(changedFormat, false, null);
    
    checkFormatting();
  };

  const handleFontMenuOpen = (event) => {
    setFontMenuAnchor(event.currentTarget);
  };

  const handleFontMenuClose = () => {
    setFontMenuAnchor(null);
  };

  const handleFontSelect = (font) => {
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    document.execCommand('fontName', false, font);
    setCurrentFont(font);
    handleFontMenuClose();
  };

  return (
    <Box>
      <Paper elevation={2} sx={{ p: 1, mb: 2 }}>
        <Box sx={{ display: 'flex', gap: 1 }}>
          <ToggleButtonGroup
            value={formats}
            onChange={handleFormat}
            aria-label="text formatting"
          >
            <ToggleButton value="bold" aria-label="bold">
              <FormatBoldIcon />
            </ToggleButton>
          </ToggleButtonGroup>
          
          <Button 
            variant="outlined" 
            onClick={handleFontMenuOpen}
            startIcon={<TextFormatIcon />}
            endIcon={<ArrowDropDownIcon />}
            size="small"
          >
            {currentFont}
          </Button>
          
          <Menu
            anchorEl={fontMenuAnchor}
            open={Boolean(fontMenuAnchor)}
            onClose={handleFontMenuClose}
          >
            {fonts.map((font) => (
              <MenuItem 
                key={font} 
                onClick={() => handleFontSelect(font)}
                selected={currentFont === font}
              >
                <span style={{ fontFamily: font }}>{font}</span>
              </MenuItem>
            ))}
          </Menu>
        </Box>
      </Paper>
      
      <Paper
        ref={editorRef}
        contentEditable
        sx={{
          p: 2,
          minHeight: '200px',
          border: '1px solid #ccc',
          borderRadius: 1,
          outline: 'none',
          '&:focus': {
            borderColor: 'primary.main',
          },
        }}
        suppressContentEditableWarning
      >
        Try changing the font using the dropdown menu. You can also make text <b>bold</b>.
      </Paper>
    </Box>
  );
}

export default NestedFormatting;

Custom Styling for ToggleButtons

You can create custom-styled toggle buttons to match your application's design:


import React, { useState, useRef, useEffect } from 'react';
import { 
  ToggleButton, 
  ToggleButtonGroup, 
  Paper, 
  Box,
  styled
} from '@mui/material';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import FormatUnderlinedIcon from '@mui/icons-material/FormatUnderlined';

// Custom styled ToggleButton
const StyledToggleButton = styled(ToggleButton)(({ theme }) => ({
  border: 'none',
  borderRadius: '50%',
  padding: '8px',
  margin: '0 4px',
  '&.Mui-selected': {
    backgroundColor: theme.palette.primary.main,
    color: theme.palette.primary.contrastText,
    '&:hover': {
      backgroundColor: theme.palette.primary.dark,
    },
  },
  '&:hover': {
    backgroundColor: theme.palette.action.hover,
  },
}));

// Custom styled ToggleButtonGroup
const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({
  backgroundColor: theme.palette.background.default,
  borderRadius: 24,
  padding: '4px',
  '& .MuiToggleButtonGroup-grouped': {
    margin: 0,
    border: 0,
  },
}));

function CustomStyledToolbar() {
  const [formats, setFormats] = useState([]);
  const editorRef = useRef(null);

  const checkFormatting = () => {
    const newFormats = [];
    if (document.queryCommandState('bold')) newFormats.push('bold');
    if (document.queryCommandState('italic')) newFormats.push('italic');
    if (document.queryCommandState('underline')) newFormats.push('underline');
    setFormats(newFormats);
  };

  useEffect(() => {
    const editor = editorRef.current;
    if (editor) {
      editor.addEventListener('mouseup', checkFormatting);
      editor.addEventListener('keyup', checkFormatting);
      
      return () => {
        editor.removeEventListener('mouseup', checkFormatting);
        editor.removeEventListener('keyup', checkFormatting);
      };
    }
  }, []);

  const handleFormat = (event, newFormats) => {
    if (document.activeElement !== editorRef.current) {
      editorRef.current.focus();
    }
    
    const changedFormat = event.currentTarget.value;
    document.execCommand(changedFormat, false, null);
    
    checkFormatting();
  };

  return (
    <Box>
      <Paper 
        elevation={3} 
        sx={{ 
          p: 1, 
          mb: 2, 
          borderRadius: 4,
          display: 'inline-block'
        }}
      >
        <StyledToggleButtonGroup
          value={formats}
          onChange={handleFormat}
          aria-label="text formatting"
        >
          <StyledToggleButton value="bold" aria-label="bold">
            <FormatBoldIcon />
          </StyledToggleButton>
          <StyledToggleButton value="italic" aria-label="italic">
            <FormatItalicIcon />
          </StyledToggleButton>
          <StyledToggleButton value="underline" aria-label="underline">
            <FormatUnderlinedIcon />
          </StyledToggleButton>
        </StyledToggleButtonGroup>
      </Paper>
      
      <Paper
        ref={editorRef}
        contentEditable
        sx={{
          p: 2,
          minHeight: '200px',
          border: '1px solid #ccc',
          borderRadius: 1,
          outline: 'none',
          '&:focus': {
            borderColor: 'primary.main',
          },
        }}
        suppressContentEditableWarning
      >
        This editor has custom-styled formatting buttons. Try selecting text and applying formatting.
      </Paper>
    </Box>
  );
}

export default CustomStyledToolbar;

Wrapping Up

In this article, we've explored how to build a rich text formatting toolbar using MUI's ToggleButton component. We started with the basics of implementing a simple bold formatting button and progressively enhanced our implementation to include multiple formatting options, state management, optimization techniques, and advanced capabilities.

MUI's ToggleButton provides an excellent foundation for building intuitive text formatting tools with its built-in state management, clear visual feedback, and extensive customization options. By following the best practices and addressing common issues outlined in this guide, you can create a robust and user-friendly text formatting experience in your React applications.