Block Kit
Three custom Gutenberg blocks built with React, sharing one set of responsive UI components.
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/lolifoks/stats-block-kit/archive/refs/heads/main.zipThree custom Gutenberg blocks built with React, sharing one set of responsive UI components.
I built this to work through block editor development end to end rather than one block in isolation: metadata-driven registration, the edit/save contract, InnerBlocks, block context, static versus dynamic rendering, and a front end script that loads only where it is needed.
The blocks
| Block | Type | What it covers |
|---|---|---|
| Stat Grid | static, container | InnerBlocks with a restricted child list and a starting template, providesContext |
| Stat | static, child | RichText with source: "html", usesContext, a viewScript count-up animation |
| Recent Resources | dynamic | render.php, useSelect against @wordpress/core-data, loading and empty states |
Stat Grid and Stat are static: their markup is written into post content at save time, which is right for text an author typed into that post. Recent Resources is dynamic: it saves only its settings and builds its markup in PHP on every request, so the list never goes stale when a listed post is renamed or a new one is published.
Responsive without generated CSS
Every block that lays content out in a grid stores one attribute:
"columns": { "type": "object", "default": { "mobile": 1, "tablet": 2, "desktop": 3 } }
src/utils/responsive.js turns that object into CSS custom properties, written
to the block wrapper as an inline style:
--bk-columns-mobile: 1; --bk-columns-tablet: 2; --bk-columns-desktop: 3;
The stylesheet reads those variables inside mobile-first media queries:
grid-template-columns: repeat(var(--bk-columns-mobile, 1), minmax(0, 1fr));
@media (min-width: 40rem) {
grid-template-columns: repeat(var(--bk-columns-tablet, 2), minmax(0, 1fr));
}
@media (min-width: 64rem) {
grid-template-columns: repeat(var(--bk-columns-desktop, 3), minmax(0, 1fr));
}
So there is no per-instance CSS, nothing injected into the head, and no
has-3-columns-desktop style class names. A hundred grids on one page share a
single stylesheet and differ only by three inline values.
The same helper runs in edit.js, in save.js, and (clamped and sanitised) in
render.php, so all three produce identical output.
The shared pieces
src/components/ holds what more than one block needs:
responsive-columns-control.jsis one inspector control that edits the whole{ mobile, tablet, desktop }object: a device toggle plus a single slider bound to whichever breakpoint is active. Stat Grid and Recent Resources both use it, so the responsive behaviour is written and tested once. It takesvalueandonChangeprops rather thanattributesandsetAttributes, so it is not welded to any one block.
src/utils/responsive.js holds the breakpoint definitions and the columns to
CSS variable conversion. Stored attributes are merged over defaults before use,
so a block saved under an older version of the schema still renders.
Block context, and its limit
Stat Grid provides blockkit/cardStyle to its children, so one setting on the
parent applies to the whole set without duplicating an attribute on each child.
Card styling itself is done with descendant selectors scoped to the parent's
class, because block context is not available inside a static save(). That is
a real constraint worth knowing: context reaches edit and dynamic
render.php, not saved markup. The child uses it for editor behaviour instead,
adjusting which controls make sense for the parent's current style.
Dynamic rendering
Recent Resources has no save() function at all. render.php treats every
attribute as untrusted input, because post content can be hand-edited and this
file runs on every page view: the post type is checked with post_type_exists()
before it reaches WP_Query, counts are clamped, and column values are clamped
to 1 to 4 before being written into a style attribute. Output is escaped at the
point of use, and get_block_wrapper_attributes() handles the wrapper class and
alignment.
The editor preview is fetched with useSelect against the core data store and
handles three distinct states: resolving, resolved but empty, and populated.
Front end script
The Stat block can animate its number when it scrolls into view. That script is
declared as viewScript in block.json, so WordPress enqueues it only on pages
that contain a stat with the option switched on. It is plain JavaScript with no
jQuery and no React, uses IntersectionObserver rather than a scroll listener,
parses the prefix and suffix out of values like 1,240 or 99.9%, and exits
early when the visitor prefers reduced motion.
The only link between the block and the script is a data-count-up attribute in
the saved markup.
Install
Requires WordPress 6.5+, PHP 7.4+, Node 18+.
git clone https://github.com/lolifoks/block-kit.git
cd block-kit
npm install
npm run build
Then activate the plugin. build/ is generated output and is not committed, so
a fresh clone needs one build step. The plugin shows an admin notice if you
forget.
For development, npm start rebuilds on save.
Structure
block-kit.php Registers all blocks from build/*/block.json
src/
components/
responsive-columns-control.js
utils/
responsive.js Breakpoints and the columns to CSS variable helper
stat-grid/ block.json, index, edit, save, styles
stat/ block.json, index, edit, save, view.js, styles
recent-resources/ block.json, index, edit, render.php, styles
There is no wp_enqueue_script call anywhere in the plugin. Every script and
stylesheet is wired up by register_block_type() reading the compiled
block.json.
Screenshots
![]() |
Stat Grid selected, responsive control open |
![]() |
Front end at 375px |
![]() |
Front end at 768px |
![]() |
Front end at 1440px |
License
GPL-2.0-or-later



