Step-by-Step Guide to building a custom real-time activity logs block for Gutenberg using Vanilla CSS shadow DOM style layers
Leveraging Shadow DOM for Encapsulated Gutenberg Block Styling
When developing custom Gutenberg blocks, maintaining style isolation is paramount, especially for dynamic components like real-time activity logs. Traditional CSS can lead to style conflicts within the complex WordPress ecosystem. This guide demonstrates how to build a custom Gutenberg block for real-time activity logs, employing Vanilla CSS and the Shadow DOM to achieve robust style encapsulation. We’ll focus on a practical implementation, detailing the necessary PHP, JavaScript, and CSS, along with a clear step-by-step process.
Plugin Structure and Block Registration
First, let’s establish a basic plugin structure. We’ll create a simple plugin that registers our custom block. The block registration process in WordPress involves PHP for server-side registration and JavaScript for client-side rendering and editor interaction.
PHP: Plugin Setup and Block Registration
Create a directory for your plugin, e.g., custom-activity-logs, within wp-content/plugins/. Inside this directory, create the main plugin file, custom-activity-logs.php.
<?php
/**
* Plugin Name: Custom Activity Logs Block
* Description: A custom Gutenberg block for displaying real-time activity logs with Shadow DOM styling.
* Version: 1.0.0
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: custom-activity-logs
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Register the custom block.
*/
function custom_activity_logs_register_block() {
// Automatically load dependencies and version.
$asset_file = include( plugin_dir_path( __FILE__ ) . 'build/index.asset.php');
wp_register_script(
'custom-activity-logs-block-editor-script',
plugins_url( 'build/index.js', __FILE__ ),
$asset_file['dependencies'],
$asset_file['version']
);
wp_register_style(
'custom-activity-logs-block-style',
plugins_url( 'build/index.css', __FILE__ ),
array(),
$asset_file['version']
);
register_block_type( 'custom-activity-logs/block', array(
'editor_script' => 'custom-activity-logs-block-editor-script',
'editor_style' => 'custom-activity-logs-block-style',
'style' => 'custom-activity-logs-block-style', // Enqueue for frontend too
) );
}
add_action( 'init', 'custom_activity_logs_register_block' );
This PHP file registers a script and a style for our block. The index.asset.php file will be generated by our build process and contains dependencies and versioning information. The register_block_type function registers the block with the name custom-activity-logs/block.
JavaScript: Block Editor Implementation
Next, we’ll set up the JavaScript for the block. This involves defining the block’s attributes, its editor representation, and its frontend rendering. We’ll use a build tool like `@wordpress/scripts` for compilation.
Project Setup and Build Process
Navigate to your plugin directory in the terminal and initialize a Node.js project:
cd wp-content/plugins/custom-activity-logs npm init -y npm install @wordpress/scripts --save-dev
Add a build script to your package.json:
{
"name": "custom-activity-logs",
"version": "1.0.0",
"description": "",
"main": "build/index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@wordpress/scripts": "^26.7.0"
}
}
Now, create the source files in a src/ directory:
mkdir src touch src/index.js src/editor.scss src/style.scss
JavaScript: Block Definition (src/index.js)
This file defines the block’s metadata, attributes, and the main rendering logic for both the editor and the frontend. We’ll use the render_callback in PHP for frontend rendering to keep the JS focused on the editor experience and initial data fetching.
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import EditorComponent from './editor'; // We'll create this component
// Import styles
import './editor.scss';
import './style.scss';
registerBlockType( 'custom-activity-logs/block', {
title: __( 'Real-time Activity Log', 'custom-activity-logs' ),
icon: 'list-view',
category: 'widgets',
attributes: {
logSource: {
type: 'string',
default: 'wp_options', // Example: could be a custom post type, user meta, etc.
},
maxEntries: {
type: 'number',
default: 10,
},
},
edit: ( { attributes, setAttributes } ) => {
const { logSource, maxEntries } = attributes;
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Log Settings', 'custom-activity-logs' ) } initialOpen={ true }>
<TextControl
label={ __( 'Log Source', 'custom-activity-logs' ) }
value={ logSource }
onChange={ ( newLogSource ) => setAttributes( { logSource: newLogSource } ) }
/>
<TextControl
label={ __( 'Max Entries', 'custom-activity-logs' ) }
type="number"
value={ maxEntries }
onChange={ ( newMaxEntries ) => setAttributes( { maxEntries: parseInt( newMaxEntries, 10 ) } ) }
/>
</PanelBody>
</InspectorControls>
<div className="custom-activity-log-editor-wrapper">
<h3>{ __( 'Activity Log Preview', 'custom-activity-logs' ) }</h3>
<EditorComponent
logSource={ logSource }
maxEntries={ maxEntries }
/>
</div>
</>
);
},
save: () => {
// Frontend rendering will be handled by PHP's render_callback
// This function returns null as we don't need to output static HTML from JS.
return null;
},
} );
JavaScript: Editor Component (src/editor.js)
This component will handle the dynamic rendering of the activity log preview within the Gutenberg editor. It will also be responsible for attaching the Shadow DOM.
import React, { useEffect, useRef, useState } from 'react';
import { __ } from '@wordpress/i18n';
const EditorComponent = ( { logSource, maxEntries } ) => {
const shadowHostRef = useRef( null );
const [ logEntries, setLogEntries ] = useState( [] );
const [ shadowRoot, setShadowRoot ] = useState( null );
// Initialize Shadow DOM
useEffect( () => {
if ( shadowHostRef.current && ! shadowHostRef.current.shadowRoot ) {
const sr = shadowHostRef.current.attachShadow( { mode: 'open' } );
setShadowRoot( sr );
// Inject styles into Shadow DOM
const style = document.createElement( 'style' );
style.textContent = `
:host {
display: block;
border: 1px solid #ccc;
padding: 10px;
font-family: sans-serif;
background-color: #f9f9f9;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
.log-entry {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px dotted #eee;
font-size: 0.9em;
color: #333;
}
.log-entry:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.log-entry strong {
color: #0056b3;
}
.log-entry span {
color: #666;
font-size: 0.85em;
margin-left: 10px;
}
h4 {
margin-top: 0;
color: #555;
font-size: 1.1em;
}
`;
sr.appendChild( style );
}
}, [] );
// Fetch dummy log data (replace with actual API call if needed)
useEffect( () => {
// In a real scenario, you'd fetch this data via WP REST API or AJAX.
// For this example, we'll simulate data.
const dummyData = Array.from( { length: maxEntries }, ( _, i ) => ( {
id: i + 1,
message: `Activity ${ i + 1 } occurred.`,
timestamp: new Date( Date.now() - ( maxEntries - i ) * 60000 ).toLocaleString(),
source: logSource,
} ) );
setLogEntries( dummyData );
}, [ logSource, maxEntries ] );
// Render content into Shadow DOM
useEffect( () => {
if ( shadowRoot ) {
// Clear previous content
shadowRoot.innerHTML = '';
const container = document.createElement( 'div' );
container.innerHTML = `<h4>${ __( 'Activity Log Preview', 'custom-activity-logs' ) }</h4>`;
if ( logEntries.length > 0 ) {
logEntries.forEach( entry => {
const entryElement = document.createElement( 'div' );
entryElement.className = 'log-entry';
entryElement.innerHTML = `
${ entry.message }
(${ entry.timestamp }) - Source: ${ entry.source }
`;
container.appendChild( entryElement );
} );
} else {
const noEntriesElement = document.createElement( 'p' );
noEntriesElement.textContent = __( 'No log entries found.', 'custom-activity-logs' );
container.appendChild( noEntriesElement );
}
shadowRoot.appendChild( container );
}
}, [ logEntries, shadowRoot ] );
return (
<div
ref={ shadowHostRef }
className="custom-activity-log-shadow-host"
style={ { minHeight: '100px', border: '1px dashed #aaa', padding: '10px', backgroundColor: '#fff' } } // Placeholder for the shadow host
>
{ /* Content will be rendered inside the Shadow DOM */ }
</div>
);
};
export default EditorComponent;
In src/editor.js:
- We use
useRefto get a reference to the host element where the Shadow DOM will be attached. - The first
useEffecthook initializes the Shadow DOM and injects the encapsulated CSS. The:hostpseudo-class targets the shadow host itself, ensuring styles are scoped. - The second
useEffecthook simulates fetching log data based on the block’s attributes. In a production environment, this would involve AJAX calls to a custom endpoint or the WordPress REST API. - The third
useEffecthook is responsible for rendering the actual log entries into the Shadow DOM. It clears previous content and appends new log entries.
CSS: Editor and Frontend Styles (src/editor.scss and src/style.scss)
src/editor.scss will contain styles specific to the editor view, while src/style.scss will contain styles for both the editor and the frontend. Importantly, the styles that need to be encapsulated within the Shadow DOM are injected directly into the Shadow DOM via JavaScript, as shown in src/editor.js. The CSS files here are for the wrapper elements outside the Shadow DOM.
/* src/editor.scss */
.custom-activity-log-editor-wrapper {
border: 1px solid #e0e0e0;
padding: 15px;
background-color: #fefefe;
border-radius: 5px;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.05);
}
.custom-activity-log-editor-wrapper h3 {
margin-top: 0;
color: #444;
font-size: 1.2em;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
margin-bottom: 15px;
}
/* src/style.scss */
.custom-activity-log-shadow-host {
/* Styles for the host element itself, outside the shadow DOM.
These might be minimal as the core styling is inside. */
margin-bottom: 1em;
}
After creating these files, run the build command:
npm run build
This will generate the build/index.js, build/index.css, and build/index.asset.php files. The index.css will contain the compiled styles from editor.scss and style.scss.
PHP: Frontend Rendering
For the frontend rendering, we’ll use a render_callback in our PHP block registration. This callback will be responsible for outputting the HTML structure that will host the Shadow DOM on the frontend.
Updating Block Registration with render_callback
Modify your custom-activity-logs.php file to include a render_callback.
<div
class="custom-activity-log-frontend-host"
data-log-source=""
data-max-entries=""
style="min-height: 100px; border: 1px dashed #aaa; padding: 10px; background-color: #fff;"
>
<!-- Content will be rendered inside the Shadow DOM by JavaScript -->
</div>
'custom-activity-logs-block-editor-script',
'editor_style' => 'custom-activity-logs-block-style',
'style' => 'custom-activity-logs-block-style',
'render_callback' => 'custom_activity_logs_render_block', // Add render_callback
) );
}
add_action( 'init', 'custom_activity_logs_register_block' );
The custom_activity_logs_render_block function outputs a simple div with data attributes that will be used by frontend JavaScript to initialize the Shadow DOM and populate it with data. Notice that we are not injecting the Shadow DOM’s internal HTML or CSS directly here. That responsibility is deferred to a dedicated frontend JavaScript file.
Frontend JavaScript for Shadow DOM Initialization
We need a separate JavaScript file to handle the Shadow DOM initialization on the frontend. This script will run after the page loads, find all instances of our block’s host element, and attach the Shadow DOM.
Create a new file src/frontend.js:
document.addEventListener( 'DOMContentLoaded', () => {
const shadowHosts = document.querySelectorAll( '.custom-activity-log-frontend-host' );
shadowHosts.forEach( host => {
if ( host.shadowRoot ) {
return; // Already initialized
}
const shadowRoot = host.attachShadow( { mode: 'open' } );
// Extract data attributes
const logSource = host.dataset.logSource || 'wp_options';
const maxEntries = parseInt( host.dataset.maxEntries, 10 ) || 10;
// Inject styles into Shadow DOM
const style = document.createElement( 'style' );
style.textContent = `
:host {
display: block;
border: 1px solid #ccc;
padding: 10px;
font-family: sans-serif;
background-color: #f9f9f9;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
.log-entry {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px dotted #eee;
font-size: 0.9em;
color: #333;
}
.log-entry:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.log-entry strong {
color: #0056b3;
}
.log-entry span {
color: #666;
font-size: 0.85em;
margin-left: 10px;
}
h4 {
margin-top: 0;
color: #555;
font-size: 1.1em;
}
`;
shadowRoot.appendChild( style );
// Fetch and render log data
const container = document.createElement( 'div' );
container.innerHTML = `<h4>Activity Log</h4>`; // Simple title for frontend
// Simulate fetching data
const dummyData = Array.from( { length: maxEntries }, ( _, i ) => ( {
id: i + 1,
message: `Frontend Activity ${ i + 1 } occurred.`,
timestamp: new Date( Date.now() - ( maxEntries - i ) * 60000 ).toLocaleString(),
source: logSource,
} ) );
if ( dummyData.length > 0 ) {
dummyData.forEach( entry => {
const entryElement = document.createElement( 'div' );
entryElement.className = 'log-entry';
entryElement.innerHTML = `
<strong>${ entry.message }</strong>
<span>(${ entry.timestamp }) - Source: ${ entry.source }</span>
`;
container.appendChild( entryElement );
} );
} else {
const noEntriesElement = document.createElement( 'p' );
noEntriesElement.textContent = 'No log entries found.';
container.appendChild( noEntriesElement );
}
shadowRoot.appendChild( container );
} );
} );
To make this frontend script available, we need to modify our build process. Add frontend.js as an entry point in your @wordpress/scripts configuration. You might need to create a .wp-scripts.json file in your plugin’s root directory:
{
"entries": [
"src/index.js",
"src/frontend.js"
]
}
Then, run npm run build again. This will generate build/frontend.js. You’ll also need to enqueue this script. A common way is to add it to the block’s dependencies in PHP, or enqueue it conditionally on the frontend.
Enqueueing Frontend Script
Modify custom-activity-logs.php to enqueue the frontend script. A robust way is to use wp_enqueue_script within a hook that fires on the frontend, ensuring it only loads when needed.
Note: If
frontend.jsis not a React component and is pure vanilla JS, its dependencies might be simpler (e.g., just `['wp-dom-ready']` or an empty array if it doesn't rely on WordPress internals). The@wordpress/scriptsbuild process will generate an asset file forfrontend.jsif it's configured as an entry point.Advanced Considerations and Best Practices
Data Fetching and Real-time Updates
The examples above use dummy data. For a true real-time activity log:
- WordPress REST API: Create a custom REST API endpoint to fetch log data. Your JavaScript (both editor and frontend) can then use
fetchorwp.apiFetchto retrieve this data. - WebSockets: For true real-time push updates, integrate a WebSocket solution (e.g., using a plugin like WP-Socket or a custom server). The JavaScript would listen for WebSocket messages and update the Shadow DOM content dynamically without requiring page reloads or polling.
- AJAX Polling: A simpler, though less efficient, approach is to use
setIntervalin JavaScript to periodically poll the REST API endpoint for new log entries.
Shadow DOM Styling Layers
The CSS injected directly into the Shadow DOM provides the primary encapsulation. This ensures that styles for the log entries, timestamps, etc., do not leak out and affect other parts of the page, nor do global styles interfere with the log's appearance. The use of :host is crucial for styling the shadow host element itself from within the shadow tree.
Accessibility
Ensure that the content rendered within the Shadow DOM is accessible. Use appropriate ARIA attributes if necessary, especially if the log entries represent interactive elements or status updates. Screen readers can generally access content within Shadow DOM, but proper semantic HTML is key.
Performance
For large log files, consider:
- Pagination/Lazy Loading: Implement mechanisms to load logs in batches rather than all at once.
- Debouncing/Throttling: If using real-time updates, debounce or throttle the update logic to prevent excessive DOM manipulations.
- Efficient Data Fetching: Optimize your API endpoints to return only the necessary data.
Conclusion
By combining Gutenberg's block API with Vanilla CSS and the Shadow DOM, we can create highly encapsulated and maintainable custom components. This approach provides robust style isolation, preventing conflicts and ensuring a consistent user experience for your real-time activity log block, both in the editor and on the frontend.