Skip to content

Creating an accessible page builder in Statamic

Developers coming from WordPress often wonder on the official Statamic Discord how to create a page builder experience in Statamic. With the various fields built into Statamic by default, it's very much possible to create a rich, accessible and semantically correct page builder.

Fields

Unlike WordPress, Statamic offers a wide variety of native built-in fields. The ones you need for a page builder are:

  1. Replicator: a meta fieldtype giving you the ability to define sets of fields that you can dynamically piece together in whatever order and arrangement you imagine.

  2. Bard: more than just a content editor, and more flexible than a block-based editor. It is designed to provide a delightful and powerful writing experience with unparalleled flexibility on your front-end.

Replicator

The Replicator lets you define sets of fields (fieldsets) that are added to a flyout menu. Content editors can hit the plus button to select a fieldset. Each set gets a title, description and icon to make it easy to find the one you need, and sets can be grouped in folders. The Replicator field will form the basis for our page builder.

Bard

The Bard fieldtype is a rich content editor, comparable to a WYSIWYG editor you might be familiar with from other content management systems. The difference being that it can also contain sets, just like the replicator fieldtype. The typical use case for Bard is long form content. Think of a blog entry with your typical text based content, enriched with sets for stuff like images, tables, embeds or pull-quotes. The set experience is similar to the sets you get with a Replicator.

Semantics

It might be tempting to use the Bard field as the basis for your page builder. However, semantically you will probably run into issues quickly. The dynamic part of a simple page typically uses the following HTML markup.

1<main>
2 <section>
3 <article>
4 <h1>Title</h1>
5 <p>Paragraph</p>
6 <figure>An image.</figure>
7 <p>Paragraph</p>
8 </article>
9 </section>
10 
11 <section>
12 A list of related blog entries.
13 </section>
14 
15 <section>
16 A call to action.
17 </section>
18</main>
Semantically correct markup.

The <main> tag is our page’s content wrapper. Above and below you might find a <header>, <nav> and <footer>, which will be defined somewhere in your layout template file.

Ideally our long form content is wrapped in an <article>, so screen readers and browser reader modes know which content to present to their users. If you were to use the Bard fieldtype as our main page builder, it will be tricky to generate semantically correct markup, because all of your content will be wrapped in one tag, presumably <article>. It can lead to markup looking like this.

1<main>
2 <article>
3 <h1>Title</h1>
4 <p>Paragraph</p>
5 <figure>An image.</figure>
6 <p>Paragraph</p>
7 
8 <section>
9 A list of related blog entries.
10 </section>
11 
12 <section>
13 A call to action.
14 </section>
15 </article>
16</main>
Semantically incorrect markup.

As you can see here, all the blocks on our page will wrapped within our one <article> block, suggesting everything belongs to each other. This might not be the worst thing for a blog entry, but a homepage for example typically contains a ton of sections that aren’t related to each other and shouldn’t be part of the same <article> tag. You could opt to not use the <article> tag, but this will remove the ability of reader modes, like Safari offers for example.

The solution I love using, is to make the Bard fieldtype a child of the main Replicator fieldtype. So an editor can add various blocks to a page and as soon as they need regular content, they add the Bard fieldtype as a section to their page. I call this block an Article, since this is similar to the tag we wrap its contents in.

Editor User Experience

So how does this all work in practice for content editors? On a simple website, my Page Builder Replicator field usually looks like this.

1title: 'Page builder'
2fields:
3 -
4 handle: page_builder
5 field:
6 type: replicator
7 display: 'Page builder'
8 button_label: 'Add block'
9 sets:
10 collections:
11 display: Collections
12 instructions: 'Collection based content.'
13 icon: content-book-open
14 sets:
15 cases:
16 display: Cases
17 instructions: 'List a selection of cases.'
18 icon: favorite-award
19 fields:
20 -
21 import: cases
22 clients:
23 display: Clients
24 instructions: 'A Clients logo cloud.'
25 icon: favorite-award
26 fields:
27 -
28 import: clients
29 interactive:
30 display: Interactive
31 instructions: 'Interactive blocks.'
32 icon: programming-script-code
33 sets:
34 call_to_action:
35 display: 'Call to action'
36 instructions: 'Show a call to action.'
37 icon: alert-alarm-bell
38 fields:
39 -
40 import: call_to_action
41 ...
42 ..
43 .
A page builder (replicator) yaml example.

Note: that for each item in the Replicator field, I import a fieldset. This keeps your fieldsets manageable and small.

This leads to the following editor user experience.

An example of the Replicator fieldtype within the Statamic Control Panel.
The replicator in action.

As you can see there are various groups of Replicator sets which I’ve used to group the various options this Page Builder offers. The Text and images group contains our Article fieldset, which is the Bard fieldtype.

As I explained, my Bard fieldtype also has various sets. The editor for the article you’re currently reading (thank you!), looks like this:

An example of the Bard fieldtype within the Statamic Control Panel.
The bard field in action.

Templating the page builder

Templating out a Replicator field is very simple, but don’t worry, I’ll make it a little more complex later. As per the docs, you can do the following to loop over your data.

1{{ page_builder scope="block" }}
2 {{ partial src="page_builder/{type}" }}
3{{ /page_builder }}
Looping over a replicator field.

In this case, our Replicator field is called page_builder. I scope the data for each set under the handle block. This makes sure that within each page builder block we can safely use fields like title without it colliding with the page title. We just have to use block:title in our page builder templates instead of title.

As you can see, for each different block in our page builder we need to have a template ready in our folder page_builder that has the same filename as our set type. So for our Article block, we need the following file: resources/views/page_builder/_article.antlers.html.

Within your fieldsets and blocks, you’re free to add whatever fields you currently need. However, each block probably has a similar markup. What I typically use as a basis is this:

1{{#
2 @name Title
3 @desc Description
4 @set page.page_builder.block_handle
5#}}
6 
7<!-- /page_builder/_my_page_builder_block.antlers.html -->
8<section class="fluid-grid">
9 {{# HTML rendering all your fields #}}
10</section>
11<!-- End: /page_builder/_my_page_builder_block.antlers.html -->
A basic page builder block template.

The fluid-grid class you see here is a technique I use the layout various page builder blocks. It gives you a centered container that fluidly scales, and includes the option to break out of the container to extend to the browsers edge. You can use whatever frontend technique you prefer here. If you’re interested in mine, read more about that in: A fluid grid to lay out page builder blocks.

Each page builder block will probably always use the same wrapping markup. And when you want to apply changes to that, it’s going to be a hassle to repeat this in all of our templates. That is why I use on dedicated partial with a slot that we can call in for each block. Here’s what the template looks like:

1{{#
2 @name Page builder block
3 @desc The page builder block wrapper
4#}}
5 
6<!-- /page_builder/_block.antlers.html -->
7<section class="fluid-grid {{ class }}">
8 {{ slot }}
9</section>
10<!-- End: /page_builder/_block.antlers.html -->
The DRY page builder block template.

It has a variable called class so we can add one-off changes on a per block basis and a slot that will be replaced with our block template contents. This is how we call it in:

1{{#
2 @name Title
3 @desc Description
4 @set page.page_builder.block_handle
5#}}
6 
7<!-- /page_builder/_my_page_builder_block.antlers.html -->
8{{ partial:page_builder/block class="optional classes" }}
9 {{# HTML rendering all your fields #}}
10{{ /partial:page_builder/block }}
11<!-- End: /page_builder/_my_page_builder_block.antlers.html -->
A basic page builder block template.

For our Article page builder block (the Bard field), we only need to do another loop to go over all the various sets it can contain. E.g: text, pull-quotes, embeds or tables. In the following example, all these elements are vertically spaced using the utility class stack-8. More about that later.

1{{#
2 @name Article
3 @desc The article page builder block. It extends the prose typography partial.
4 @set page.page_builder.article
5#}}
6 
7<!-- /page_builder/_article.antlers.html -->
8{{ partial:page_builder/block }}
9 {{ partial:typography/prose as="article" class="contents stack-8" }}
10 {{ article }}
11 {{ partial src="components/{type}" }}
12 {{ /article }}
13 {{ /partial:typography/prose }}
14{{ /partial:page_builder/block }}
15<!-- End: /page_builder/_article.antlers.html -->
The article block template.

You can see in my case I extend another (seriously?) partial that will take care of rendering an <article> tag with all the Tailwind Typography classes that I need per project. This however, is completely optional and my way of dealing with type in Tailwind. If you’re curious, this is how it looks:

1{{#
2 @name Prose
3 @desc The typography prose partial to render a prose object with `class` attribute.
4 @param as The wrapping element. Defaults to `article`.
5 @param class Add custom CSS classes.
6#}}
7 
8<!-- /typography/_prose.antlers.html -->
9<{{ as or 'article' }}
10 class="
11 prose
12 prose-a:underline
13 hover:prose-a:text-primary
14 max-w-none
15 {{ class }}
16 "
17>
18 {{ slot }}
19</{{ as or 'article' }}>
20<!-- End: /typography/_prose.antlers.html -->
The article block template.

The reason I use another extendable template is for reusability. In each projects, I will have multiple pieces of data I want to render all in the same text style. This makes that happen.

Vertical spacing

To vertically space page builder blocks, I wrap them within a <main> tag. This tag uses stacks to space blocks within:

1{{#
2 @name Default
3 @desc The default template.
4#}}
5 
6<!-- /default.antlers.html -->
7<main class="py-12 md:py-16 lg:py-24 stack-12 md:stack-16 lg:stack-24" id="content">
8 {{ page_builder scope="block" }}
9 {{ partial src="page_builder/{type}" }}
10 {{ /page_builder }}
11</main>
12<!-- End: /default.antlers.html -->
The article block template.

Those classes basically do the following:

  1. py-*: Sets a vertical padding on the wrapper. The padding increases for bigger breakpoints: md and lg.

  2. stack-*: Gives each child that is preceded by a sibling a margin-top.

This technique sounds complex, but is extremely useful. It allows you to adjust the spacing on a per block basis or even completely remove it. You can read more about this in: Stack utilities to space page builder blocks.

Try it out today

I can imagine this being a lot of information, especially if you come from a different CMS and are new to Statamic. However, you can see these methodologies in action today. My Starter Kit Peak, which I use for every website I develop, is free and open source for you to use as well.

Peak comes with paid CLI tools that let you quickly add page builder blocks to your replicator and generate the required fieldsets and partials. This time-saver will quickly become invaluable. Check it out here:

A gif showing the CLI tools in Peak to create a page builder block on the fly.
One of the Peak CLI tools in action.

Need more help?

If you need any help with these techniques, or how to implement Peak in an efficient way, there’s a community run Peak Discord and I offer paid support, workshops or pair programming sessions.

Code Rush Podcast

  • Podcasts
  • CSS
  • JS
  • Statamic
  • Tailwind CSS
  • Alpine JS