Skip to main content

Theme Development

A specification for modern, modular WordPress theme development. This document defines enforceable standards for design tokens, CSS architecture, block development, and interactive logic.

Philosophy

Our themes are built around a small set of guiding ideas:

  • theme.json is the single source of truth for design tokens. Editor and frontend stay in sync without duplicate CSS.
  • Block-scoped everything. CSS and JS load only when a block actually renders, keeping the global footprint small.
  • Islands of interactivity. Alpine.js powers behavior on the specific elements that need it — no global JS init pass, no framework lock-in.
  • Pick the right reuse primitive. Regular patterns for one-off layouts, synced patterns with overrides when design must stay consistent but content varies, custom blocks when server-side logic or behavior can't be expressed by composition. See §6.1 for the decision tree.
  • Reproducible builds. Every project builds identically inside the standard DevContainer.

Design System Foundation

Before code, every project should start from a deliberate design system. A well-structured Figma file — a true design system, not just mockups — gives engineering a clear contract for tokens, components, and states. That Figma source then maps directly into theme.json (colors, typography, spacing) so the editor UI matches the frontend without reinterpretation.

1. Environment & Dependencies

DependencyMinimum VersionNotes
WordPressLatest stable (min 6.9)Required for Block Bindings API and Synced Pattern Overrides
PHP8.1Typed properties, readonly properties, enums
Node.js20 LTSUsed by @wordpress/scripts build pipeline
ACF Pro6.2+Required for ACF Blocks with Block Bindings
Alpine.js3.xLoaded once globally; components defined per block

All projects run inside the standard DevContainer. Build processes must be reproducible via npm run build from the theme root with no host-machine dependencies.

2. Design Tokens

2.1 theme.json as the Single Source of Truth

All design tokens (colors, typography, spacing, layout widths, shadows, radii) are defined in theme.json. Tokens are consumed via the generated CSS custom properties (--wp--preset--*) on both the frontend and the editor. Duplicate token definitions in SCSS are prohibited.

When a token must be overridden for a specific block, the override is scoped to that block's stylesheet using the block's wrapper class (.wp-block-{namespace}-{name}) and references the parent token rather than redefining it.

2.2 Color Palette

Palette entries use semantic slugs, not literal color names:

{
"settings": {
"color": {
"palette": [
{ "slug": "primary", "name": "Primary", "color": "#..." },
{ "slug": "secondary", "name": "Secondary", "color": "#..." },
{ "slug": "accent", "name": "Accent", "color": "#..." },
{ "slug": "surface", "name": "Surface", "color": "#..." },
{ "slug": "text-default", "name": "Text", "color": "#..." }
]
}
}
}

Generated custom properties (--wp--preset--color--primary, etc.) are the only acceptable way to reference palette colors in stylesheets. If a brand color changes, a single edit in theme.json propagates everywhere — including blocks.

2.3 Fluid Typography

Typography uses theme.json's settings.typography.fluid with explicit min and max values per preset.

  • Minimum viewport: 320px
  • Maximum viewport: 1440px
  • Type scale ratio: 1.25 (major third) at mobile, 1.333 (perfect fourth) at desktop
  • Base size: 1rem mobile, 1.125rem desktop

Each font-size preset defines fluid: { min, max }. Fixed px font sizes are prohibited outside of utility cases (icons, badges).

2.4 Spacing Scale

Spacing uses a theme.json preset scale on a 4px base unit, exposed as --wp--preset--spacing--{slug}.

SlugValue
2xs4px
xs8px
sm12px
md16px
lg24px
xl32px
2xl48px
3xl64px
4xl96px

Hardcoded margin/padding values in stylesheets are prohibited.

3. Style Architecture

3.1 Resolution Order

Styling decisions resolve in the following order. Move to the next step only if the current step cannot satisfy the requirement.

🚧 NOTE: We should discuss this, and whether we should go completely native CSS - no SCSS
  1. theme.json setting or preset
  2. Block Style Variation registered via register_block_style()
  3. Per-block stylesheet enqueued via wp_enqueue_block_style()
  4. Shared theme stylesheet enqueued via enqueue_block_assets
  5. Custom CSS (requires justification in PR description)

3.2 SCSS Usage

SCSS is used for organization (partials, nesting, mixins) and compiles to CSS that emits native custom properties. SCSS variables are not used for design tokens; they are used only for build-time logic (breakpoint maps, mixin parameters).

Partials live in /assets/scss/ and follow the naming convention _category-name.scss (e.g., _mixins-fluid.scss, _reset.scss).

3.3 Prohibited Patterns

  • Targeting .wp-block-* selectors from global stylesheets
  • Utility-first frameworks (Tailwind, Tachyons) — they create a parallel token system that conflicts with theme.json
  • !important declarations outside of accessibility utilities (.screen-reader-text, .visually-hidden)
  • Inline style attributes in render.php for values that exist as tokens

4. Block Development

4.1 File Structure

Every custom block is self-contained in /blocks/{block-name}/:

/blocks/accordion/
block.json # Block metadata and asset registration
render.php # Server-side render callback
style.scss # Frontend styles (compiles to style.css)
editor.scss # Editor-only styles (compiles to editor.css)
view.js # Alpine component, frontend behavior
index.js # Editor registration (if dynamic block with controls)
README.md # Usage, props, examples

This makes blocks plug-and-play across client projects. Favor agnostic, reusable blocks over project-specific one-offs.

4.2 Registration

Blocks are registered via register_block_type() pointing at the block's directory. The block's block.json declares style, editorStyle, and viewScript so that assets are enqueued only when the block renders on the page.

4.3 Naming

  • Block namespace: {client-or-org}/{block-name} — e.g., acme/accordion
  • Block wrapper class: .wp-block-{namespace}-{name} (auto-generated)
  • Custom CSS properties scoped to a block: --{namespace}-{block}-{property} — e.g., --acme-accordion-border-color

4.4 Definition of Done

A block is considered complete when all of the following are true:

  • block.json declares all assets, supports, and attributes
  • render.php produces semantic, accessible HTML
  • Editor preview matches frontend rendering (parity verified manually)
  • Passes axe-core accessibility audit with zero violations
  • Has a registered Pattern preview or Storybook entry
  • README documents attributes, supports, and example usage
  • Per-block JS payload ≤ 5KB gzipped (excluding shared Alpine core)

5. Interactive Logic (Alpine.js)

5.1 Island Architecture

Alpine.js is loaded once globally (~15KB gzipped). Each interactive block defines its component in view.js and mounts it via x-data on its wrapper element. JavaScript executes only against DOM nodes that declare it; there is no global initialization pass beyond Alpine itself.

5.2 Scoping Rules

  • Each block's Alpine component is defined under a unique name: Alpine.data('{namespace}{Block}', () => ({ ... }))
  • Components do not reach outside their root element via document.querySelector
  • Cross-block communication uses Alpine's $dispatch with namespaced event names: acme:cart:item-added
  • Global state (cart, modal stack) lives in a single Alpine.store('app', ...) declared in the theme's main script

5.3 Performance Targets

  • First-block hydration: < 50ms after DOMContentLoaded
  • Total JS payload (Alpine + all block view scripts on a typical page): < 40KB gzipped

6. Patterns

6.1 Pattern vs. Block Decision

There are three reuse primitives. Pick the lowest-cost one that satisfies the requirement; only escalate when it doesn't.

PrimitiveUse whenTradeoff
Regular PatternA layout is genuinely one-off, or you want each insertion to diverge over timeEdits to the source pattern do not propagate to existing insertions — every instance is independent after insertion
Synced Pattern with OverridesThe same component appears in multiple places, design must stay consistent, but content (text, images, links) varies per instanceCentralized layout edits propagate everywhere; only fields explicitly exposed via metadata.bindings are editable per instance (see §6.3)
Custom BlockServer-side data fetching, dynamic rendering, or behavior that can't be expressed by composing core blocksHighest authoring cost, but a single source of truth in code; changes always propagate

Rule of thumb: if you find yourself re-inserting a regular pattern after every design tweak, you needed a Synced Pattern with Overrides. If you're reaching for a custom block because "we keep changing it," reconsider — that's still a synced-pattern problem unless server logic is involved.

6.2 Block Locking

Patterns intended for client editing declare a locking strategy in their template definition:

StrategytemplateLock valueUse case
Content OnlycontentOnlyClient edits text/images; structure preserved
All LockedallBrand-critical layouts; no client edits
Insert LockedinsertExisting blocks editable; no new blocks added
UnlockedfalseFree-form sections (rare for patterns)

Default for client-facing patterns is contentOnly.

6.3 Synced Pattern Overrides

Synced Patterns with Overrides are used when a design must remain consistent across instances but specific content fields vary. Common case: a "Team Member Card" with locked layout but per-instance Name, Bio, and Photo. Each editable field declares its metadata.bindings to expose it as an override target.

7. ACF Integration

ACF is used for complex data entry that exceeds the practical limits of native Gutenberg attribute controls (repeaters, relationship fields, conditional logic). Two integration paths:

  1. Block Bindings (preferred where supported): ACF fields bind to native blocks via metadata.bindings. Maintains full Gutenberg performance and theme.json compatibility.
  2. ACF Blocks with Inline Editing: Used when bindings are insufficient (deep repeaters, complex conditional UIs). Inline Editing is enabled to preserve native editing experience.

ACF field groups are version-controlled as JSON in /acf-json/ with acf/settings/save_json and acf/settings/load_json filters configured.

8. Build Process

8.1 Scripts

The package.json exposes the following scripts. All are required to exist with these exact names:

ScriptPurpose
npm run devWatch mode with sourcemaps; hot reload via BrowserSync
npm run buildProduction build; minification, no sourcemaps
npm run lintESLint + Stylelint + PHPCS (WordPress-Extra ruleset)
npm run formatPrettier + phpcbf
npm run testPHPUnit + Jest (where applicable)

8.2 DevContainer

All builds run identically inside the project's DevContainer. The container provides Node, PHP, Composer, and WP-CLI. Host-machine builds are not supported and not debugged.

8.3 Open Items

  • Webfont generation pipeline: needed, or rely on Google Fonts via theme.json fontFamilies?
  • Audit existing build scripts across active projects for consolidation candidates

9. Anti-Patterns

The following are explicitly prohibited and will fail code review:

  • Registering blocks via register_block_type() without a block.json
  • Defining design tokens in SCSS variables instead of theme.json
  • Targeting core block selectors (.wp-block-paragraph, .wp-block-heading) from theme stylesheets
  • Loading per-block JavaScript via wp_enqueue_script() from functions.php instead of block.json viewScript
  • Adding utility frameworks (Tailwind, etc.) alongside the token system
  • Creating Page Templates for layouts that should be Patterns
  • Using !important outside of explicitly documented accessibility utilities
  • Hardcoding spacing or color values that exist as theme.json tokens
  • Cross-block DOM queries that bypass Alpine's component boundaries

Owner: TBD | Last reviewed: TBD