Packaging the Baseline Status Web Component as a WordPress Block

The Baseline project tracks when web platform features become available across major browsers, and the <baseline-status> web component renders that data on a page. Embedding the component directly works fine for a single location, but for a site like the Almanac, where feature status belongs inside many articles and posts, a WordPress block makes the component reusable across the editor without touching markup by hand.

Here’s how that block was built, from scaffolding to styling.

Scaffolding with create-block

The project started with @wordpress/create-block. Running the install command inside /wp-content/plugins generates a standard plugin directory:

npm install @wordpress/create-block

The plugin’s main file, baseline-status.php, handles registration. It follows the same pattern as a theme’s functions.php style.css equivalent, except the create-block package automates much of the boilerplate:

<?php
/**
 * Plugin Name:       Baseline Status
 * Plugin URI:        https://css-tricks.com
 * Description:       Displays current Baseline availability for web platform features.
 * Requires at least: 6.6
 * Requires PHP:      7.2
 * Version:           0.1.0
 * Author:            geoffgraham
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       baseline-status
 *
 * @package CssTricks
 */

if ( ! defined( 'ABSPATH' ) ) {
  exit; // Exit if accessed directly.
}

function csstricks_baseline_status_block_init() {
  register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'csstricks_baseline_status_block_init' );

?>

The substantial code lives in src, with block.json pre-populated by the scaffolding tool:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 2,
  "name": "css-tricks/baseline-status",
  "version": "0.1.0",
  "title": "Baseline Status",
  "category": "widgets",
  "icon": "chart-pie",
  "description": "Displays current Baseline availability for web platform features.",
  "example": {},
  "supports": {
    "html": false
  },
  "textdomain": "baseline-status",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

WordPress blocks render twice, once each for the front and back end. Those views live in separate files under src:

  • render.php: builds the front-end output
  • edit.js: builds the back-end editor view

Front-End and Editor Markup

The web component’s usage pattern calls for an inline <script> tag next to the markup:

<script src="https://cdn.jsdelivr.net/npm/[email protected]/baseline-status.min.js" type="module"></script>
<baseline-status featureId="anchor-positioning"></baseline-status>

Injecting a script each time the block appears is wasteful, so the script is enqueued conditionally from the main plugin file based on whether the block is actually on the page:

// ... same code as before

// Enqueue the minified script
function csstricks_enqueue_block_assets() {
  wp_enqueue_script(
    'baseline-status-widget-script',
    'https://cdn.jsdelivr.net/npm/[email protected]/baseline-status.min.js',
    array(),
    '1.0.4',
    true
  );
}
add_action( 'enqueue_block_assets', 'csstricks_enqueue_block_assets' );

// Adds the 'type="module"' attribute to the script
function csstricks_add_type_attribute($tag, $handle, $src) {
  if ( 'baseline-status-widget-script' === $handle ) {
    $tag = '<script type="module" src="' . esc_url( $src ) . '"></script>';
  }
  return $tag;
}
add_filter( 'script_loader_tag', 'csstricks_add_type_attribute', 10, 3 );

// Enqueues the scripts and styles for the back end
function csstricks_enqueue_block_editor_assets() {
  // Enqueues the scripts
  wp_enqueue_script(
    'baseline-status-widget-block',
    plugins_url( 'block.js', __FILE__ ),
    array( 'wp-blocks', 'wp-element', 'wp-editor' ),
    false,
  );

  // Enqueues the styles
  wp_enqueue_style(
    'baseline-status-widget-block-editor',
    plugins_url( 'style.css', __FILE__ ),
    array( 'wp-edit-blocks' ),
    false,
  );
}
add_action( 'enqueue_block_editor_assets', 'csstricks_enqueue_block_editor_assets' );

Bundling the script directly into the plugin keeps it compliant with the WordPress Plugin Directory guidelines on third-party executable code. A small helper, csstricks_add_type_attribute(), tags the file as an ES module. The newer wp_enqueue_script_module() function would handle that, but it didn’t work reliably in this case.

The front-end markup goes in render.php, wrapped with the recommended get_block_wrapper_attributes() which outputs all block attributes for debugging:

<baseline-status
  <?php echo get_block_wrapper_attributes(); ?> 
  featureId="[FEATURE]">
</baseline-status>

A placeholder, [FEATURE], marks where the target web platform feature will be inserted. The block needs a registered attribute for that value, defined in block.json:

"attributes": { "showBaselineStatus": {
  "featureID": {
  "type": "string"
  }
},

Once registered, render.php echoes the featureID attribute:

<baseline-status
  <?php echo get_block_wrapper_attributes(); ?> 
  featureId="<?php echo esc_html( $featureID ); ?>">
</baseline-status>

The same markup appears in edit.js so the component renders inside the editor. useBlockProps is the JavaScript counterpart to get_block_wrapper_attibutes() and serves the same debugging role:

<baseline-status { ...useBlockProps() } featureId={ featureID }></baseline-status>

At that stage the block worked but accepted no user input and always displayed the same hard-coded feature.

Adding Editable Settings

WordPress exposes the same controls it uses for its own blocks, so they can be imported and extended. The imports in edit.js pull in the needed pieces:

import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import './editor.scss';

Those imports provide:

  • InspectorControls for the block settings panel
  • useBlockProps for block wrapper data
  • PanelBody as the main settings container
  • TextControl as the text field for entering a feature ID
  • editor.scss for control styling

The main Edit function wraps everything:

export default function Edit( { attributes, setAttributes } ) {
  // Controls
}

Inside the settings panel, the text input control binds to the block attribute:

export default function Edit( { attributes, setAttributes } ) {
  <>
    <InspectorControls>
      <PanelBody title={ __( 'Settings', 'baseline-status' ) }>
        // Controls
        <TextControl
          label={ __(
            'Feature', // Input label
            'baseline-status'
          ) }
          value={ featureID || '' }
          onChange={ ( value ) =>
            setAttributes( { featureID: value } )
          }
        />
     </PanelBody>
    </InspectorControls>
  </>
}

Then comes the wiring. The featureID attribute is defined in edit.js, registered in block.json, and echoed in render.php:

const { featureID } = attributes;
"attributes": {
  "featureID": {
    "type": "string"
  }
},
<baseline-status
  <?php echo get_block_wrapper_attributes(); ?>
  featureId="<?php echo esc_html( $featureID ); ?>">
</baseline-status>

Styling the Shadow DOM

Because the component uses Shadow DOM, its internal styles stay contained. That isolation is a strength of web components, but it means page styles can’t reach inside by default. The component’s author made parts of the Shadow DOM styleable, which requires peeking at the source to see what’s exposed.

Basic type-selector styling works directly on the <baseline-status> element:

baseline-status {
  background: #000;
  border: solid 5px #f8a100;
  border-radius: 8px;
  color: #fff;
  display: block;
  margin-block-end: 1.5em;
  padding: .5em;
}

The component’s source defines CSS color variables, so instead of hard-coding values, those variables can be redefined at the block level:

baseline-status {
  --color-text: #fff;
  --color-outline: var(--orange);

  border: solid 5px var(--color-outline);
  border-radius: 8px;
  color: var(--color-text);
  display: block;
  margin-block-end: var(--gap);
  padding: calc(var(--gap) / 4);
}

One idea considered but set aside was conditionally hiding the component’s <h1> title. That only makes sense in contexts where the page heading already matches the feature, which won’t hold true for most placements.

Future Improvements

The current version handles a single feature lookup with no live preview. Desired enhancements include:

  • Live update: the editor view doesn’t refresh the component’s output until the page reloads
  • Variations: presets like “large” and “small” for the component
  • Heading control: a toggle to show or hide the component’s internal title