BlueTheme Docs

Adding an element

This tutorial adds a Notice element, a colored message box that visitors can close, from a child theme. The same folder works in a plugin. The full format of each file is in the builder internals.

Where elements live

Each element is one folder with a config.json. BlueTheme looks in three places. A later place wins when two folders have the same name, so a child theme can replace a theme element by copying its folder.

  1. bluetheme/elements/: the theme's own elements.
  2. bluetheme-child/elements/: the active child theme's elements.
  3. Any folder added with the bluetheme_builder_element_dirs filter, for plugins:
add_filter( 'bluetheme_builder_element_dirs', function ( array $dirs ): array {
	$dirs[ plugin_dir_path( __FILE__ ) . 'elements' ] = plugin_dir_url( __FILE__ ) . 'elements';
	return $dirs;
} );

The Notice element goes in bluetheme-child/elements/notice/:

elements/notice/
  config.json   the element's name, category and settings
  render.php    prints its markup
  style.css     loaded only on pages that use it
  script.js     the same, deferred, in the footer

1. config.json

notice/config.json

{
	"title": "Notice",
	"description": "A colored message box with an icon, which visitors can close.",
	"category": "boxes",
	"icon": "bi:megaphone",
	"keywords": [
		"alert",
		"message"
	],
	"summary": "title",
	"textdomain": "bluetheme-child",
	"fields": [
		{
			"type": "select",
			"id": "kind",
			"label": "Kind",
			"default": "info",
			"choices": {
				"info": "Information",
				"success": "Success",
				"warning": "Warning"
			}
		},
		{
			"type": "icon",
			"id": "icon",
			"label": "Icon",
			"default": "bi:info-circle"
		},
		{
			"type": "text",
			"id": "title",
			"label": "Title",
			"default": "Good to know",
			"translate_default": true
		},
		{
			"type": "textarea",
			"id": "text",
			"label": "Text",
			"default": ""
		},
		{
			"type": "switch",
			"id": "dismissible",
			"label": "Visitors can close it",
			"default": true
		},
		{
			"type": "color",
			"id": "accent",
			"label": "Accent color",
			"default": "",
			"tab": "style",
			"css": {
				"selector": "{{WRAPPER}} .notice-box",
				"var": "--notice-accent"
			}
		},
		{
			"type": "number",
			"id": "space",
			"label": "Space inside",
			"default": "",
			"unit": "px",
			"responsive": true,
			"tab": "style",
			"css": {
				"selector": "{{WRAPPER}} .notice-box",
				"property": "padding",
				"unit": "px"
			}
		}
	],
	"style_selectors": {
		"typography": "{{WRAPPER}} .notice-box__text"
	}
}
  • title, description, category, icon, keywords: how the element appears in the builder's Elements tab. The description is the tooltip. The category is one of typography, media, boxes, interactive, sliders, dynamic, post, woocommerce, product, header or other. The icon is any pack:name the icon picker knows.
  • summary: the field the backend builder shows on the element's card.
  • textdomain: the text domain that translates the labels. Leave it out for the theme's own bluetheme.
  • fields: the element's settings, using the Theme Options field types. Fields go on the Content tab unless tab says style or advanced.
    • responsive: true stores one value per device.
    • translate_default: true translates the default text.
    • css turns the value into CSS: {{WRAPPER}} is the element's own class, and the rule sets either a property or a custom property (var). CSS is written only for values that differ from the default, into the page's generated stylesheet, per device.
  • style_selectors: points the shared Style settings (here Typography) at a part of the element instead of its outer box.

Every element also gets the shared Style and Advanced settings (spacing, background, border, animation, display conditions and the rest), so don't add those yourself.

2. render.php

notice/render.php

<?php
/**
 * Notice element: a colored message box with an icon, which visitors can
 * close.
 *
 * @package BlueTheme_Child
 *
 * @var array<string, mixed> $settings Element settings.
 */

defined( 'ABSPATH' ) || exit;

use BlueTheme\Builder\Elements\Parts;
use BlueTheme\Builder\Render\Renderer;
use BlueTheme\Icons\Registry as Icons;

$bluetheme_child_title = (string) $settings['title'];
$bluetheme_child_text  = (string) $settings['text'];

// Nothing to show: a hint in the builder, nothing on the site.
if ( '' === $bluetheme_child_title && '' === $bluetheme_child_text ) {
	if ( Renderer::$editing ) {
		echo Parts::hint( __( 'Write a title or a text for the notice.', 'bluetheme-child' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by Parts.
	}
	return;
}

$bluetheme_child_kind = in_array( $settings['kind'], array( 'info', 'success', 'warning' ), true ) ? $settings['kind'] : 'info';

echo '<div class="notice-box notice-box--' . esc_attr( $bluetheme_child_kind ) . '" role="note">';
if ( '' !== (string) $settings['icon'] ) {
	echo '<span class="notice-box__icon" aria-hidden="true">' . Icons::render( (string) $settings['icon'] ) . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Icon markup is built by the theme.
}
echo '<div class="notice-box__body">';
if ( '' !== $bluetheme_child_title ) {
	echo '<p class="notice-box__title">' . esc_html( $bluetheme_child_title ) . '</p>';
}
echo Parts::text( $bluetheme_child_text, 'notice-box__text' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by Parts.
echo '</div>';
if ( $settings['dismissible'] ) {
	echo '<button type="button" class="notice-box__close" aria-label="' . esc_attr__( 'Close the notice', 'bluetheme-child' ) . '">×</button>';
}
echo '</div>';
  • $settings holds the saved values over the defaults. A per-device value is a map such as { "desktop": 24, "mobile": 12 }, but you don't need it here: the css key handles it.
  • Print the element's inner markup only. The theme wraps it in <div class="bt-el bt-el-notice bt-n-{id}">.
  • Escape everything. Parts has escaped helpers for images, headings, text with line breaks, links, buttons and phone links; BlueTheme\Icons\Registry::render() returns the markup of an icon field.
  • Renderer::$editing is true in the live builder. Use it to show a hint instead of an empty space when required content is missing.

3. style.css

notice/style.css

/* Notice element. Logical properties, so it works in both directions. */
.notice-box {
	--notice-accent: var(--bt-color-primary);

	display: flex;
	gap: 0.75rem;
	align-items: flex-start;
	padding: 1rem 1.25rem;
	border-inline-start: 4px solid var(--notice-accent);
	border-radius: var(--bt-radius);
	background: color-mix(in srgb, var(--notice-accent) 10%, transparent);
}

.notice-box--success {
	--notice-accent: #0f7b46;
}

.notice-box--warning {
	--notice-accent: #a15c00;
}

.notice-box__icon {
	flex: none;
	color: var(--notice-accent);
	font-size: 1.25rem;
	line-height: 1.4;
}

.notice-box__body {
	flex: 1;
	min-inline-size: 0;
}

.notice-box__title {
	margin: 0 0 0.25rem;
	color: var(--bt-color-heading);
	font-weight: 700;
}

.notice-box__text > :last-child {
	margin-block-end: 0;
}

.notice-box__close {
	flex: none;
	padding: 0 0.25rem;
	border: 0;
	background: none;
	color: inherit;
	font-size: 1.5rem;
	line-height: 1;
	cursor: pointer;
}

.notice-box.is-closed {
	display: none;
}
  • Use logical properties (margin-inline-start, border-inline-start, inset-inline-end) so the element works in right-to-left and left-to-right languages without a second stylesheet. The theme's pnpm lint:css rejects left and right.
  • Use the theme's custom properties (--bt-color-primary, --bt-color-heading, --bt-radius) so the element follows Theme Options → Colors.
  • Put the default look here; the css fields only add what a user changes.

4. script.js

notice/script.js

/**
 * Notice element: the close button hides the box. One listener on the
 * document also covers boxes the live builder adds later.
 */
document.addEventListener( 'click', ( event ) => {
	const button = event.target.closest( '.notice-box__close' );
	// In the live builder a click selects the element instead.
	if ( ! button || document.body.classList.contains( 'bt-canvas' ) ) {
		return;
	}
	button.closest( '.notice-box' ).classList.add( 'is-closed' );
} );
  • Plain JavaScript, not bundled; no jQuery.
  • The live builder replaces sections while someone edits. Listen on the document, as here, or set elements up again on the bluetheme:render event, which fires on the replaced part.
  • In the live builder body has the bt-canvas class. Skip actions there that would leave the page or hide the element.

5. Translate it

The theme translates labels with the domain in textdomain. WordPress's wp i18n make-pot doesn't read JSON, so list the texts in a PHP file that is never loaded, and translate that domain as usual:

<?php
// elements/strings.php: texts from config.json, for translators only.
__( 'Notice', 'bluetheme-child' );
__( 'A colored message box with an icon, which visitors can close.', 'bluetheme-child' );
__( 'Kind', 'bluetheme-child' );
// …every label, description and choice.

The theme's own elements get this file from tools/element-strings.mjs (pnpm i18n).

6. Try it

  1. Open a page in the live builder. Notice is in the Elements tab under Boxes.
  2. Drag it onto the page and change its settings. Pick Tablet or Mobile in the device switcher and change Space inside: only that device changes.
  3. Clear the title and the text. The builder shows the hint; the published page shows nothing.
  4. Save and view the page as a visitor: the close button hides the notice. The element's style.css and script.js load only on pages that use it.

Tip This example is tested: it lives in docs/dev/examples/elements/notice/, and pnpm lint:js, pnpm lint:css and composer lint check it with the theme's own code.

More

  • Libraries: "requires": [ "swiper" ] loads a bundled library before the element's script. See libraries.
  • Plugin elements: "needs": "woocommerce" hides the element while the plugin is off.
  • Repeaters and galleries, choices from the site, and assets shared with another element: builder internals.
  • The page data format an element is saved in: document.