How to Customize WordPress Gutenberg Blocks (Without Building From Scratch)
WordPress's Block Editor comes with powerful built-in blocks, but they often lack the customization options needed for real projects. Instead of building custom blocks from scratch, you can extend the existing ones with just a few lines of code.
In this guide, I'll show you exactly how I extended the core Table block to add features like sticky columns, custom header colors, and CTA buttons.
The Problem with Building Custom Blocks
When you need a feature that doesn't exist in a core block, you might think the solution is to build a custom block. But this approach has significant drawbacks:
- Duplicated effort - You're recreating functionality WordPress already provides
- Maintenance burden - You need to keep up with WordPress updates
- Accessibility concerns - Core blocks are thoroughly tested for accessibility
- Learning curve - Block development requires understanding React, the Block API, and more
A Better Approach: Extend Core Blocks
WordPress provides a powerful filter system that lets you modify existing blocks. You can:
- Add new attributes to store custom data
- Inject controls into the block's settings panel
- Apply custom CSS classes based on those settings
- Keep all the original functionality intact
Let's see how this works with a real example.
Real Example: Extending the Table Block
I needed tables with these features for a client project:
- Multiple header color themes
- Sticky first column for comparison tables
- CTA buttons inside cells
- Minimum cell width controls
Instead of building a custom table block, I extended the core one. Here's how.
Step 1: Add Custom Attributes
First, hook into block registration to add your custom attributes:
import { addFilter } from '@wordpress/hooks';
function addTableBlockAttributes(settings) {
// Only modify the core/table block
if (settings.name !== 'core/table') {
return settings;
}
// Add custom attributes
settings.attributes = {
...settings.attributes,
headerBackgroundColor: {
type: 'string',
default: 'primary',
},
};
return settings;
}
addFilter(
'blocks.registerBlockType',
'my-plugin/table-attributes',
addTableBlockAttributes
);
What's happening here:
blocks.registerBlockTypefilter runs when any block is registered- We check if it's the table block before modifying
- New attributes extend existing ones (spread operator preserves originals)
Step 2: Add Inspector Controls
Next, wrap the block's edit component to inject your custom controls:
import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl, SelectControl } from '@wordpress/components';
import { addFilter } from '@wordpress/hooks';
function TableBlockEdit({ BlockEdit, ...props }) {
// Skip non-table blocks
if (props.name !== 'core/table') {
return <BlockEdit {...props} />;
}
const { attributes, setAttributes } = props;
// Helper to toggle CSS classes
function handleClassToggle(checked, classToToggle) {
let newClassName = attributes?.className?.trim() || '';
newClassName = newClassName.replace(
new RegExp(`\\s?${classToToggle}`),
''
);
if (checked) {
newClassName = `${newClassName} ${classToToggle}`.trim();
}
setAttributes({ className: newClassName });
}
// Parse current state from className
const className = attributes?.className || '';
const freezeFirstColumn = className.includes('freeze-first-col');
return (
<>
<BlockEdit {...props} />
<InspectorControls>
<PanelBody title="Table Style Options">
<ToggleControl
label="Freeze First Column"
checked={freezeFirstColumn}
onChange={(checked) =>
handleClassToggle(checked, 'freeze-first-col')
}
help="Keep first column visible when scrolling"
/>
</PanelBody>
</InspectorControls>
</>
);
}
// Higher-order component wrapper
function addControlsToTableBlock(BlockEdit) {
return (props) => <TableBlockEdit BlockEdit={BlockEdit} {...props} />;
}
addFilter(
'editor.BlockEdit',
'my-plugin/table-controls',
addControlsToTableBlock
);
Key concepts:
editor.BlockEditfilter wraps the edit component- We render the original plus our controls
- State is stored in the
classNameattribute (WordPress persists this automatically) - CSS classes are toggled on/off based on user selections
Step 3: Add Frontend Styles
The CSS classes we're adding need corresponding styles:
:root {
--table-cell-padding: 16px 24px;
--table-header-primary: #1e40af;
--table-header-dark: #1f2937;
}
.wp-block-table {
overflow-x: auto;
// Sticky first column
&.freeze-first-col {
th:first-child,
td:first-child {
position: sticky;
left: 0;
z-index: 2;
// Shadow effect when scrolling
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 100%;
width: 10px;
background: linear-gradient(
to right,
rgba(0, 0, 0, 0.1),
transparent
);
}
}
}
// Header color variants
&.header-dark th {
background-color: var(--table-header-dark);
}
}
Why CSS custom properties?
- Easy to override in themes
- Single source of truth for values
- Better maintainability
Step 4: Add Shortcodes for Advanced Features
Some features need dynamic HTML that can't be achieved with CSS classes alone. For CTA buttons inside table cells, I use a shortcode:
function my_table_cta_shortcode($atts) {
$atts = shortcode_atts([
'url' => '#',
'label' => 'Click Here',
'newtab' => 'true',
'nofollow' => 'false',
], $atts);
$target = filter_var($atts['newtab'], FILTER_VALIDATE_BOOLEAN)
? ' target="_blank" rel="noopener"'
: '';
$rel = filter_var($atts['nofollow'], FILTER_VALIDATE_BOOLEAN)
? ' rel="nofollow"'
: '';
return sprintf(
'<a href="%s" class="table-cta"%s%s>%s</a>',
esc_url($atts['url']),
$target,
$rel,
esc_html($atts['label'])
);
}
add_shortcode('table_cta', 'my_table_cta_shortcode');
Usage in table cells:
[table_cta url="https://example.com" label="Get Started" nofollow="true"]
Project Structure
Here's how I organize the plugin:
wp-table-block-extended/
├── wp-table-block-extended.php # Bootstrap & PHP shortcodes
├── src/
│ ├── Plugin.php # Main plugin class
│ ├── Assets.php # Script/style enqueueing
│ ├── Shortcodes/
│ │ ├── CTA.php
│ │ └── Placeholder.php
│ ├── index.js # Editor customizations
│ ├── editor.scss # Editor styles
│ └── style.scss # Frontend styles
├── build/ # Compiled assets
└── package.json
Benefits of This Approach
| Aspect | Custom Block | Extended Core Block |
|---|---|---|
| Development time | Days/weeks | Hours |
| WordPress compatibility | Manual updates needed | Automatic |
| Accessibility | You handle it | WordPress handles it |
| User familiarity | New UI to learn | Same table block they know |
| Maintenance | High | Low |
When to Build a Custom Block Instead
Extending core blocks isn't always the right choice. Build a custom block when:
- The functionality is completely different from any core block
- You need a fundamentally different editing experience
- The core block's HTML structure doesn't support your needs
- You're building something reusable across many projects
Try It Yourself
I've open-sourced the complete Table Block Extended plugin:
The code includes:
- Modern PHP 8.1+ with OOP architecture
- PSR-4 autoloading
- Optional Composer tooling for code quality
- Comprehensive documentation
Conclusion
Before building a custom Gutenberg block, consider whether you can extend an existing one. You'll save development time, get automatic WordPress compatibility updates, and provide users with a familiar editing experience.
The filter system (blocks.registerBlockType and editor.BlockEdit) is powerful enough to handle most customization needs. Combined with CSS classes and shortcodes, you can add sophisticated features while keeping your codebase minimal.
Have questions about extending Gutenberg blocks? Get in touch or check out the full source code.