Reusable Gutenberg Block Styles
Registers reusable Gutenberg block styles (.is-style-* classes) for core and custom blocks from one central configuration file, loading only the CSS a page actually needs, on both the frontend and in the block editor.
by Studio Zonder Meer · github.com/yelbow/reusable-gutenberg-block-styles · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/yelbow/reusable-gutenberg-block-styles/archive/refs/heads/main.zipOne plugin that registers custom Gutenberg block styles (the choices in a block's Styles panel) for many sites at once, plus the default look of those blocks. Code lives in Git, styles live in CSS files, and every site can switch individual styles on or off without forking the plugin.
This repository is the plugin: reusable-gutenberg-block-styles.php sits at the
top level, so you can zip the repo, upload it, or point the optional GitHub
updater at it.
Group → Default · Card · Feature · Dark · Section · Full viewport · Padding S/M/L
Columns → Wide gap · Tight gap
Column → Card
Button → Default · Outline · Pill · Link
Buttons → Equal width
Image → Default · Rounded · Rounded corners · Shadow
Heading → Eyebrow
Paragraph → Lead
Cover → Panel
Query → Framed
Post Template → Cards
List → Chips
Post Terms → Chips
Tag Cloud → Chips
The Button and Image lists include the styles core already ships
(Default, Outline, Rounded) to show where they come from. This plugin
deliberately does not re-register those, see
Where this meets core's own styles.
Why this exists
The usual alternative is register_block_style() plus a pile of .is-style-*
rules per site, in each child theme, copy-pasted forward. That drifts: four sites,
four slightly different Cards, and every fix has to be made four times.
Here the registration lives in one configuration array, the CSS lives in one folder per block, and a site only ever decides which styles it wants. Updating the plugin updates the design system on every site that has it installed; templates and content stay in the site's own theme where they belong.
It uses register_block_style() and nothing else. No editor UI is rebuilt, no
block patterns, no block variations, no block templates, no settings screen.
Install on a site
- Copy this folder into
wp-content/plugins/reusable-gutenberg-block-styles/(the folder name matters for updates), or upload a zip via Plugins → Add New → Upload Plugin. - Activate.
- Open any Group block, look at Styles: Card, Feature, Dark, Section, Full viewport and the Padding variants are there.
Nothing else is needed. The plugin creates no database tables, no options, no
custom post types, no roles, and has no admin screen. Deactivate to remove it;
uninstall.php only clears the update-check transient.
Package it as a zip
bin/build-zip.sh # writes dist/reusable-gutenberg-block-styles-1.0.0.zip
The script reads the version from the plugin header, so bumping Version: is the
only step needed before a release. .distignore decides what is left out (Git
metadata, the dist folder itself, editor config).
Updates across sites
By default the plugin never phones home. To update sites from GitHub releases
instead of uploading zips by hand, point it at a repository, either in the site's
wp-config.php:
define( 'RGBS_GITHUB_REPO', 'studiozondermeer/reusable-gutenberg-block-styles' );
or from a mu-plugin / child theme:
add_filter( 'rgbs_github_repo', fn() => 'studiozondermeer/reusable-gutenberg-block-styles' );
From then on the repository's latest release shows up in that site's
Plugins/Updates screen exactly like a WordPress.org plugin, and updating is a
normal one-click update from wp-admin. Publish a release with a version tag
(v1.0.0) and put reusable-gutenberg-block-styles.zip in the release assets,
or let it use GitHub's own zip of the tag. Private repositories and API rate
limits are handled with an optional
define( 'RGBS_GITHUB_TOKEN', '...' ), or the rgbs_github_token filter.
This is deliberately small and dependency-free. If you want the same behaviour with a maintained third-party library, the Plugin Update Checker library used in the SZM Admin Menu Manager does the same job with more polish.
The configuration file
Everything you normally touch is in config/styles.php. It returns one array:
return array(
'block_styles' => array(
'core/group' => array(
array( 'name' => 'card', 'label' => __( 'Card', 'reusable-gutenberg-block-styles' ) ),
),
),
'default_styles' => array(
'core/group' => array( 'css' => array( 'frontend' => 'blocks/group/default.css' ) ),
),
);
A flat array keyed by block name also works ('core/group' => array( ... ) with
no block_styles wrapper); it is treated as the selectable styles.
Each style accepts:
| Key | What it does |
|---|---|
name |
Required. Becomes the CSS class .is-style-{name}. |
label |
Shown in the Styles panel. Pass a translated string. Omitted: derived from name. |
enabled |
false switches the style off for this site. Default true. |
css |
array( 'frontend' => ..., 'editor' => ... ), or a single string. Paths are relative to assets/css/, or absolute. |
inline_style |
Raw CSS string instead of a file. |
style_data |
theme.json-style array (WP 6.6+); WordPress generates the CSS from your design tokens. |
is_default |
Marks this style as the one the editor treats as active when no style class is set. |
override |
Allow replacing a style that is already registered for that block. See below. |
args |
Anything else you want to pass straight to register_block_style(). |
A bare slug works too: 'card' is the same as
array( 'name' => 'card' ), with the label derived from the slug.
Add a style in three steps
- Add an entry in
config/styles.php:
'core/group' => array(
// ...
array(
'name' => 'sticky-note',
'label' => __( 'Sticky note', 'reusable-gutenberg-block-styles' ),
),
),
-
Create
assets/css/blocks/group/sticky-note.css. The path is derived from the block and the style name, so you only need to remember the pattern. -
Scope the selector to the block, never to the class alone:
.wp-block-group.is-style-sticky-note {
background-color: var(--wp--preset--color--accent-4, #fff8d6);
padding: var(--wp--preset--spacing--40, 1.5rem);
rotate: -1deg;
}
.is-style-sticky-note on its own would also hit a Column, an Image or anything
else using the same slug. Every file in this plugin is scoped that way.
Where the CSS files go
assets/css/
├── blocks/
│ ├── <block>/<style>.css frontend + editor canvas
│ ├── <block>/<style>-editor.css editor only
│ └── ...
└── templates/ examples that are not loaded
<block> is the block name without its namespace (core/group → group). For a
custom block, my-plugin/fancy-card becomes my-plugin--fancy-card, or point
css at a path of your own.
A missing file is not an error. A style can be label-only, or carry its CSS
through inline_style or style_data. Two blocks that should share one
stylesheet can both point at the same file:
'core/column' => array(
array(
'name' => 'card',
'label' => __( 'Card', 'reusable-gutenberg-block-styles' ),
'css' => array( 'frontend' => 'shared/card.css' ),
),
),
Styles without a CSS file (style_data)
Core 6.6+ accepts style_data, a theme.json-shaped array, and generates the CSS
from the site's global styles. It is supported and passed straight through, and it
is the most native way to define a style that should also be editable in the Site
Editor's Global Styles panel:
array(
'name' => 'lead',
'label' => __( 'Lead', 'reusable-gutenberg-block-styles' ),
'style_data' => array(
'typography' => array(
'fontSize' => 'var:preset|font-size|large',
'lineHeight' => '1.6',
),
),
),
The catch, and the reason the shipped styles do not use it: style_data names
preset slugs, so it only looks as designed on a theme that uses those slugs. That
makes it a good fit for a style belonging to one site and a poor fit for a design
system that ships to several. Use it where the slugs are known; use a CSS file
with the token chain above where the style has to travel.
Switch a style off per site
Keep the entry, set enabled to false. The style is not registered, no CSS is
loaded for it, and the label disappears from the editor:
array(
'name' => 'dark',
'label' => __( 'Dark', 'reusable-gutenberg-block-styles' ),
'enabled' => false,
),
That is fine for one site. For several sites running the same plugin checkout, leave the config alone and decide per site from a small mu-plugin, so the plugin files stay identical everywhere:
add_filter(
'rgbs_block_styles',
function ( $styles ) {
foreach ( $styles['core/group'] as $i => $definition ) {
if ( 'dark' === $definition['name'] ) {
$styles['core/group'][ $i ]['enabled'] = false;
}
}
return $styles;
}
);
Extend it from another plugin or theme
Add a style for a block that this plugin does not configure:
add_filter(
'rgbs_block_styles',
function ( $styles ) {
$styles['core/quote'][] = array(
'name' => 'bordered',
'label' => __( 'Bordered', 'my-textdomain' ),
'css' => array( 'frontend' => 'blocks/quote/bordered.css' ),
);
return $styles;
}
);
Definitions added here are normalised the same way the config file is, so slugs,
labels, enabled, css, inline_style and style_data all behave identically.
Register a style from PHP directly, without any configuration:
\RGBS\BlockStyles::register( 'core/group', 'sticky-note', 'Sticky note' );
\RGBS\BlockStyles::register(
array(
'block' => 'core/group',
'name' => 'sticky-note',
'label' => 'Sticky note',
)
);
Both are safe at any point in the request: before init the call is queued and
processed with the rest, after init it happens immediately.
Hooks
| Hook | Type | Purpose |
|---|---|---|
rgbs_config |
filter | The whole normalised configuration. |
rgbs_config_path |
filter | Load the configuration from a different file. |
rgbs_block_styles |
filter | Add, change or disable selectable styles per block. |
rgbs_default_styles |
filter | Add or change default block styling. |
rgbs_style_files |
filter | Point a style's CSS at files elsewhere (a theme, a shared folder). |
rgbs_style_properties |
filter | Last stop before register_block_style(); change any argument. |
rgbs_register_style |
filter | Return false to keep one style out. |
rgbs_style_handle |
filter | Change the stylesheet handle of a style. |
rgbs_block_slug |
filter | Change the directory slug used for a block's files. |
rgbs_stylesheet_url |
filter | Supply a URL for a CSS path WordPress cannot map itself. |
rgbs_booted |
action | All components hooked. |
rgbs_style_registered |
action | One style registered. |
rgbs_style_disabled |
action | A style was skipped because enabled is false. |
rgbs_style_conflict |
action | A style was refused because the slug is taken. |
rgbs_styles_registered |
action | All styles done, with a map of what registered. |
rgbs_github_repo / rgbs_github_token |
filter | Enable and configure the optional updater. |
Design tokens: nothing is hard-coded
This is the rule that keeps the plugin portable, so it is worth stating plainly: no stylesheet in this plugin assumes a palette entry, a spacing preset or a font size slug. Every value resolves through the active theme, in this order:
--wp--style--block-gap the theme's own rhythm (theme.json), emitted by WordPress
↓ not set?
--wp--preset--spacing--50 a preset, used only as a fallback
↓ not set?
1.5rem a literal, only when the theme defines nothing
Colour follows the same idea but differently, because a palette entry is a choice the theme made and this plugin has no business making it:
- fills and tints are mixed from the text colour the block inherited
(
color-mix(in srgb, currentColor 5%, transparent)), so a card is a raised surface on a light palette, on a dark palette and inside an inverted section, without naming a colour; - borders use
currentColor; - sizes are
emwhere they should follow the theme's type scale, so Lead is 1.15x whatever the theme's body size is, and an Eyebrow is never larger than the heading it sits on.
Measured on a block theme with --wp--style--block-gap: 1.2rem and body text at
21.76px: card padding 27.2px (the 1.25em floor, because the 19.2px rhythm was
tighter than the text), chip gap 9.6px (half the rhythm), Lead 25.03px, and a
Group → Dark resolving to the theme's own contrast/base pair. Change the theme's
theme.json and every one of those numbers follows, without touching this
plugin.
Radius and shadow are literals. They are what a Card is: WordPress has no
radius or shadow scale to inherit, and "1rem, 10/30 shadow" is the style's
identity rather than a theme decision. The one exception to the colour rule is
Group → Dark, which needs two contrasting colours and therefore reads the
theme's contrast/base pair, with currentColor as a fallback.
A single spacing knob is exposed per style if you want it tighter or roomier, without editing the plugin:
.wp-block-group.is-style-card { --rgbs-space: 2rem; }
How the CSS is loaded
The short version: WordPress loads a style's CSS when the block it belongs to is
on the page, on the frontend as well as in the editor. Nothing loads for blocks
that are not there, and you do not write a single wp_enqueue_style() call.
Concretely, a style that resolves to a CSS file is registered as a stylesheet
handle (rgbs-group-card) and handed to WordPress twice:
- as the
style_handleof the registered block style, the native declaration that says "this style needs this stylesheet"; - through
wp_enqueue_block_style(), which does the same job through a path that is registered earlier in the request.
Both point at the same handle, so the file is printed once. The second one is not
belt-and-braces for its own sake: as of WordPress 7.0, core adds its
style_handle loading from enqueue_block_assets, which runs after the
template content has already been rendered, so a style declared only that way
never loads on a block-theme frontend. That was measured, not assumed; the
evidence is in DECISIONS.md.
What that means per block, in practice:
- A page with a Group loads the Group stylesheets; a page without one loads none of them.
- A Group loads all of its stylesheets, not only the one you picked, because the decision is made per block, not per class. Files are small and this matches what core does with its own per-block stylesheets. Reading the content to get per-class precision would break dynamic blocks, template parts and query loops, which is why it is not done.
- The editor loads every registered style's CSS, so picking a style shows its real appearance immediately. That is core behaviour for block styles and it is what makes the editor match the frontend.
Editor-only CSS (`