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.jsonis 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
| Dependency | Minimum Version | Notes |
|---|---|---|
| WordPress | Latest stable (min 6.9) | Required for Block Bindings API and Synced Pattern Overrides |
| PHP | 8.1 | Typed properties, readonly properties, enums |
| Node.js | 20 LTS | Used by @wordpress/scripts build pipeline |
| ACF Pro | 6.2+ | Required for ACF Blocks with Block Bindings |
| Alpine.js | 3.x | Loaded 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}.
| Slug | Value |
|---|---|
2xs | 4px |
xs | 8px |
sm | 12px |
md | 16px |
lg | 24px |
xl | 32px |
2xl | 48px |
3xl | 64px |
4xl | 96px |
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
theme.jsonsetting or preset- Block Style Variation registered via
register_block_style() - Per-block stylesheet enqueued via
wp_enqueue_block_style() - Shared theme stylesheet enqueued via
enqueue_block_assets - 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 !importantdeclarations outside of accessibility utilities (.screen-reader-text,.visually-hidden)- Inline
styleattributes inrender.phpfor 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.jsondeclares all assets, supports, and attributesrender.phpproduces 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
$dispatchwith 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.
| Primitive | Use when | Tradeoff |
|---|---|---|
| Regular Pattern | A layout is genuinely one-off, or you want each insertion to diverge over time | Edits to the source pattern do not propagate to existing insertions — every instance is independent after insertion |
| Synced Pattern with Overrides | The same component appears in multiple places, design must stay consistent, but content (text, images, links) varies per instance | Centralized layout edits propagate everywhere; only fields explicitly exposed via metadata.bindings are editable per instance (see §6.3) |
| Custom Block | Server-side data fetching, dynamic rendering, or behavior that can't be expressed by composing core blocks | Highest 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:
| Strategy | templateLock value | Use case |
|---|---|---|
| Content Only | contentOnly | Client edits text/images; structure preserved |
| All Locked | all | Brand-critical layouts; no client edits |
| Insert Locked | insert | Existing blocks editable; no new blocks added |
| Unlocked | false | Free-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:
- Block Bindings (preferred where supported): ACF fields bind to native blocks via
metadata.bindings. Maintains full Gutenberg performance andtheme.jsoncompatibility. - 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:
| Script | Purpose |
|---|---|
npm run dev | Watch mode with sourcemaps; hot reload via BrowserSync |
npm run build | Production build; minification, no sourcemaps |
npm run lint | ESLint + Stylelint + PHPCS (WordPress-Extra ruleset) |
npm run format | Prettier + phpcbf |
npm run test | PHPUnit + 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.jsonfontFamilies? - 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 ablock.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()fromfunctions.phpinstead ofblock.jsonviewScript - Adding utility frameworks (Tailwind, etc.) alongside the token system
- Creating Page Templates for layouts that should be Patterns
- Using
!importantoutside of explicitly documented accessibility utilities - Hardcoding spacing or color values that exist as
theme.jsontokens - Cross-block DOM queries that bypass Alpine's component boundaries
Related
Owner: TBD | Last reviewed: TBD