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:
- Toggle State Management: It manages selected/unselected states internally, simplifying your code.
- Visual Feedback: It provides clear visual cues for the current state.
- Grouping Capability: ToggleButtonGroup allows for exclusive or non-exclusive selection modes.
- Accessibility: Built-in keyboard navigation and ARIA attributes ensure good accessibility.
- 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.
Prop | Type | Default | Description |
---|---|---|---|
value | any | required | The value to associate with the button when selected |
selected | bool | false | If true, the button is rendered in a selected state |
onChange | func | - | Callback fired when the state changes |
disabled | bool | false | If true, the component is disabled |
disableFocusRipple | bool | false | If true, the keyboard focus ripple is disabled |
disableRipple | bool | false | If true, the ripple effect is disabled |
fullWidth | bool | false | If 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:
Prop | Type | Default | Description |
---|---|---|---|
value | any | - | The currently selected value(s) within the group |
exclusive | bool | false | If true, only allow one button to be selected at a time |
onChange | func | - | 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:
- 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>
- 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>
);
}
- 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:
- Provide meaningful labels: Use
aria-label
for icon-only buttons. - Group related buttons: Use ToggleButtonGroup with
aria-label
to indicate the purpose of the group. - 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:
- Created a ToggleButtonGroup to hold our formatting options
- Added a single ToggleButton for bold formatting
- Set up state management to track which formatting options are active
- 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:
- A TextField component for editing text
- State management for the text content
- A ref to access the text field's DOM element
- 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:
- Uses a contentEditable element instead of a TextField
- Applies real bold formatting using
document.execCommand('bold')
- Detects current formatting using
document.queryCommandState('bold')
- Updates the ToggleButton state to reflect the formatting of the current selection
- 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:
- Adds buttons for italic and underline formatting
- Checks for all three formatting types when updating the state
- Determines which format was changed and applies only that formatting
- 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:
- Text formatting options (bold, italic, underline)
- Text alignment controls (left, center, right, justify)
- Font size selection
- Placeholder buttons for text and background color
- Undo and redo functionality
- Tooltips for better usability
- 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:
- Debouncing the formatting checks
- Memoizing components
- 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:
- Debouncing the formatting check to avoid excessive updates
- Using callback refs instead of useRef with useEffect
- Memoizing the toolbar component to prevent re-renders when unrelated state changes
- 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:
- Integrates with React Hook Form using the Controller component
- Syncs the contentEditable div's HTML with the form state
- Handles form submission with the formatted content
- 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
-
Maintain State Synchronization: Always keep the toggle button state in sync with the actual formatting of the text. Check formatting when selection changes.
-
Provide Visual Feedback: Use clear icons and consistent visual cues to indicate which formatting options are active.
-
Handle Focus Management: Ensure the editor is focused when formatting is applied, but preserve the text selection.
-
Optimize Performance: Debounce frequent operations like checking formatting on selection changes.
-
Support Keyboard Shortcuts: Implement common keyboard shortcuts (Ctrl+B for bold, etc.) for better usability.
-
Ensure Accessibility: Use proper ARIA labels and ensure keyboard navigation works correctly.
-
Consider Mobile Usage: Make buttons large enough for touch interaction and consider responsive layouts for smaller screens.
Common Issues and Solutions
- 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);
- 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}
/>
)}
/>
- 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'));
};
- 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.