I've always wanted web design to be more like print design.
My roots are in graphic design. I started out using QuarkXpress and Adobe Photoshop sometime around 1990. When the mid-90s rolled around, I immediately jumped on board building websites, but I felt like I was constantly beating my head against the wall trying to create web pages that looked like print. You may not remember Raygun magazine, but this iconic alternative rock mag was my design inspiration and what I aspired to achieve in web design. Sadly, creating beautiful design on the early web was a never ending nightmare. We were constantly battling with browser disparities, bandwidth, overly complex table based layouts, and a lack of good development tools. I hoped that someday the massive amounts of code required to build a website would fade away, and websites would created in the same way that we designed pages for print. Fast forward to 2019 and we are still writing tons of code. However, the dev tools are much better, browsers are more consistent, and features like grid and flexbox layout have made it possible to achieve great designs.
At this point you may be wondering why I'm talking about graphic design and waxing poetic about Raygun magazine instead of talking about a content builder? Simply put, my goal as a Craft CMS developer is to hand over a tool (like the content builder) that allows my client to create beautiful design. I want web page design to be easy and fun so they are more inclined to create content. While we developers are still writing tons of code, our clients should never be exposed to the madness behind the scenes!
Content Builder Overview
I'm not the first person to write about a Craft content builder and I'm definitely not trying to take credit for the concept. There are plenty of other examples that you may want to explore. I'm offering my unique approach in hopes that it may inspire your next project.
In case you're unfamiliar with the concept of a content builder, I'll give you a brief explanation. With the Matrix field in Craft, you can embed any of the other field types except for another Matrix field. Since sub-fields of a Matrix field can be drag-drop arranged in an entry, you can rearrange the content on your page however you like!
My original content builder was created using the native Matrix field with SuperTable. I started with this approach because it was free, but why did I use SuperTable? Sometimes you may need to nest an element like a button in your parent element. The button may have several sub-elements like a link, background color, target, etc. To avoid confusion in the publishing interface, the button should ideally appear as a sub-group of objects, so combining them in a SuperTable makes sense. If a Matrix field inside of a Matrix field was possible in Craft, we could do away with SuperTable. As a side note, it looks like Matrix in Matrix will be added to Craft 4.0.
You may be wondering why I referenced the Matrix + SuperTable as my "original" content builder solution? After developing my content builder using M + ST field types, I came to a daunting conclusion. If I wanted to animate any or all of the content builder blocks I would need to repeat the same animation fields in every block. The graphic below should explain my conundrum a little better:
For each block created in the Matrix above, I need to add the three unique animation fields – but they are all the same. Repeating the same animation fields in each Matrix block may not be a big deal if you only have two or three block types. But I currently have ten different blocks, so I was facing the absolute misery of creating thirty fields instead of just three! It made my OCD flare up 😜, and I needed to find a DRY solution!
I thought back to a conversation with a colleague. I talked about my content builder solution and he mentioned that he uses NEO instead of nesting SuperTable fields in Matrix. At the time, I was trying to avoid using paid plugins, and besides, I had already written my whole content builder using M + ST. But faced with the untenable chore of repeating the same fields over and over, I thought it was time to reconsider NEO.
NEO solved multiple issues for me
First, NEO allowed me to reuse the same field across all content blocks. NEO is set up like Craft's field layout manager, so the interface is familiar. I started by creating a field group named "Content Builder Elements". These fields include rich text fields, lightswitches, dropdowns, assets and more.
Next, I created a NEO field and within that field I created all of my content blocks. Content blocks include "content width image", "full width video", "text", "blockquote" and more.
After I created my content blocks, I created tab structures within each block to organize fields. By default my tabs are "Content", "Margins" and "Animation".
Finally, I included the required fields for each content block within their respective tabs. This is an example of my setup:
Not only is this dryer solution, but it also means that the template code to render the element is the basically same for every template. I have not yet moved the repeated chunks of code into macros, but I plan to optimize that soon.
Which brings me to another issue resolved by NEO – a cleaner and more organized publishing interface. NEO's tab structure is much easier for content publishers to understand:
I question why the functionality of NEO is not native functionality in Craft? Hopefully we will see this in future versions of Craft.
Organizing the templates
I've played around with different ways of organizing my templates. I refer to each block type as a "module" in my template naming scheme. The goal was to create a single entry point for all modules so that I can call the content builder in the same way for every template. This is my file layout:
templates
_content-builder
_includes
_moduleBuilder.twig
_moduleBlockQuote.twig
_moduleCode.twig
_moduleCta.twig
_moduleEmbeddedAsset.twig
_moduleHalfHalf.twig
_moduleImageContent.twig
_moduleInfoBlock.twig
_moduleText.twig
_moduleVideoBg.twig
_layout
_base.twig
_blog
index.twig
web
assets
css
app.css
moduleBlockquote.css
moduleCode.css
moduleCta.css
moduleEmbeddedAsset.css
moduleHalfHalf.css
moduleImageContent.css
moduleImageFull.css
moduleInfoBlock.css
moduleText.css
moduleVideoBg.css
js
prism.js
I'll start with the base layout template, templates/_layout/_base
which is my overall page wrapper:
<!DOCTYPE html>
<html lang="en-US">
<head>
<title>Content Builder | Vaughn D. Taylor</title>
<link rel="stylesheet" href="{{ mix('css/app.css') }}">
</head>
<body class="{{ sectionClass | default(null) }}{{ entryClass | default(null) }}">
<main id="main" class="main">
{% block main %}{% endblock %}
</main>
</body>
</html>
I'm sure that the base template is very familiar to you, but I just want to make sure you understand the all the working parts.
Next, is my blog template, templates/blog/index
which extends my base template:
{% extends "_layout/_base" %}
{% set sectionClass = craft.app.request.segments|first != '' ? craft.app.request.segments|first : 'home' %}
{% set entryClass = craft.app.request.getSegment(2) != '' ? ' entry' : '' %}
{% block main %}
{% include '_content-builder/_includes/_moduleBuilder' %}
{% endblock %}
Note that I'm setting a few classes that will be added to the body using ternary operators. I like to do this in case I need to create a special exception for styling that applies only to specific section or type of entry.
Within the main block, I include the entry point for the module builder. Let's have a look at the full module builder, templates/_content-builder/_includes/_moduleBuilder.twig
, then I'll break it down below:
{# EAGER LOAD MODULES #}
{% set modules = entry.contentBuilder
.with([
'moduleBlockquote',
'moduleCode',
'moduleCta',
'moduleEmbeddedAsset',
'moduleHalfHalf',
'moduleImageContent',
'moduleImageFull',
'moduleInfoBlock',
'moduleText',
'moduleVideoBg'
])
.level(1)
.all()
%}
{# LOOP THROUGH MODULES #}
{% for module in modules %}
<div class="{{ module.type }}">
{% include '_content-builder/_' ~ module.type %}
</div>
{% endfor %}
{# MAKE A NEW ARRAY WITH MODULES #}
{% set moduleArray = [] %}
{# REMOVE DUPLICATE MODULE TYPES FROM ARRAY #}
{% for moduleType in modules %}
{% if moduleType.type not in moduleArray %}
{% set moduleArray = moduleArray|merge([moduleType.type]) %}
{% endif %}
{% endfor %}
{# CREATE EMBEDDED CSS FOR EACH MODULE IN ARRAY #}
{% for moduleCSS in moduleArray %}
{{ craft.mix.withTag('css/' ~ moduleCSS ~ '.css', true) | raw }}
{% endfor %}
{# CREATE EMBEDDED JS FOR moduleCode IN ARRAY #}
{% for moduleJS in moduleArray %}
{% if moduleJS.handle == 'moduleCode' %}
{{ craft.mix.withTag('js/prism.js') | raw }}
{% endif %}
{% endfor %}
The first thing I do is eager load all of the modules in the content builder. If you're not familiar with eager loading in Craft, it basically uses the with
criteria parameter to tell Craft which sub-elements you’re going to be needing in advance, so that it can fetch them all up front, in as few queries as possible. The level(1)
parameter is specific to NEO, so if you're not using NEO you will not use this.
{# EAGER LOAD MODULES #}
{% set modules = entry.contentBuilder
.with([
'moduleBlockquote',
'moduleCode',
'moduleCta',
'moduleEmbeddedAsset',
'moduleHalfHalf',
'moduleImageContent',
'moduleImageFull',
'moduleInfoBlock',
'moduleText',
'moduleVideoBg'
])
.level(1)
.all()
%}
Next, I loop through all of the modules creating a div with the class that matches the module.type
name, and then I include the module from the _content-builder directory. Calling the module.type
(which is the module's block name) means that I can segregate the code for each module into its own file.
{# LOOP THROUGH MODULES #}
{% for module in modules %}
<div class="{{ module.type }}">
{% include '_content-builder/_' ~ module.type %}
</div>
{% endfor %}
Just make sure to name your module template the same as your block handle. For example:
There are other ways to retrieve the blocks, but I feel like this is the cleanest way. Originally, I was using switch
with the module type handle as the comparison to loop through the blocks, but it's a much messier solution than the small for
loop above.
Next I'm creating an array of the all the modules that were loaded on the page which I will be using to generate embedded CSS and JS for each module:
{# MAKE A NEW ARRAY WITH MODULES #}
{% set moduleArray = [] %}
{# REMOVE DUPLICATE MODULE TYPES FROM THE FULL ARRAY OF OBJECTS LOADED ON THE PAGE #}
{% for moduleType in modules %}
{% if moduleType.type not in moduleArray %}
{% set moduleArray = moduleArray|merge([moduleType.type]) %}
{% endif %}
{% endfor %}
{# CREATE EMBEDDED CSS FOR EACH MODULE IN CLEAN ARRAY #}
{% for moduleCSS in moduleArray %}
{{ craft.mix.withTag('css/' ~ moduleCSS ~ '.css', true) | raw }}
{% endfor %}
{# CREATE EMBEDDED JS FOR moduleCode IF IT WAS INCLUDED IN THE ARRAY #}
{% for moduleJS in moduleArray %}
{% if moduleJS.handle == 'moduleCode' %}
{{ craft.mix.withTag('js/prism.js') | raw }}
{% endif %}
{% endfor %}
First, take note that I'm using the Craft Mix plugin to output the CSS and JS. I do this because it there are options available in the plugin which make it easy to output embedded code.
But why use embedded code instead of compressing all of the CSS into a single external file? You'll never know how many modules are included on each page – one page could use a single module, and the next page could use every module. Also, external CSS can block rendering of the page until it's completely loaded which may become an issue if your CSS file is large. The obvious downside of my technique is that I cannot take advantage of browser caching for a single CSS file. However, I do wrap the full content area of each page using {% cache %}
. Either way you choose to do this is up to you – this is just my technique.
Let's continue with a template used to render a single module, templates/_module-builder/_moduleText.twig
:
{% set attributes = {
class: [
'moduleText__wrapper',
module.intro ? 'mod--intro',
module.backgroundColor ? module.backgroundColor : '',
module.removeMargin ? 'mod--no-margin',
module.marginTop != 'null' ? module.marginTop,
module.marginRight != 'null' ? module.marginRight,
module.marginBottom != 'null' ? module.marginBottom,
module.marginLeft != 'null' ? module.marginLeft
]
} %}
{% if module.animationType is not empty %}
{% set attributesAnimation = {
data: {
'sal': module.animationType != '' ? module.animationType : 'fade',
'sal-delay': module.animationDelay != '' ? module.animationDelay : '400',
'sal-easing': module.animationEasing != '' ? module.animationEasing : 'ease'
}
} %}
{% endif %}
<div{{ attr(attributes) }}{% if attributesAnimation is defined %}{{ attr(attributesAnimation) }}{% endif %}>
{{ module.richText }}
</div>
You may not be familiar with the way I'm setting the attributes here because the attr()
function is completely new in Craft 3.2. You can read more about it on CraftSnippets, but basically it allows us to avoid jamming all the attributes for an element into a single, unreadable line. In the template above, I'm breaking the class and data attributes out into two different variables because I need to output the data attributes for my animation conditionally. I don't want to end up with empty data attributes if animation is not chosen for the module.
If you haven't yet tried to create a content builder, I recommend you do. Your clients will be thrilled at the results! If you have any questions, please feel free to reach out to me on Discord @vaughndtaylor.
But, before I go...
I want to show you a very tricky module, templates/_content-builder/_moduleHalfHalf.twig
. First is the rendered end result followed by the template code (good luck trying to unravel that mystery!)
We all need a content builder!
While we may not all agree on the best way to create a content builder, we can all agree it's a necessity.
{% if module.animationType is not empty %}
{% set animationType = module.animationType %}
{% set animationDelay1 = module.animationDelay|number %}
{% set animationDelay2 = module.animationDelay|number * 2 %}
{% set animationDelay3 = module.animationDelay|number * 3 %}
{% set animationEasing = module.animationEasing %}
{% endif %}
{% set image = module.image.one() %}
{% if image is not empty %}
{% set transformedImages = craft.imager.transformImage(image,
[
{ format: 'jpg', width: 600, height: 400, jpegQuality: 72 },
{ format: 'jpg', width: 400, height: 267, jpegQuality: 55 }
],
{
position: image.getFocalPoint(),
sharpen: true,
interlace: 'partition'
}
) %}
{% set blurredImage = craft.imager.transformImage(image,
{
format: 'jpg',
width: 800,
height: 600,
jpegQuality: 60,
position: image.getFocalPoint(),
interlace: 'partition',
effects: {
modulate: [15, 150, 66]
}
}
) %}
{% endif %}
{% set attributesWrapper = {
class: [
'moduleHalfHalf__wrapper',
module.removeMargin ? 'mod--no-margin',
module.marginTop != 'null' ? module.marginTop,
module.marginRight != 'null' ? module.marginRight,
module.marginBottom != 'null' ? module.marginBottom,
module.marginLeft != 'null' ? module.marginLeft,
module.backgroundColor ? module.backgroundColor,
module.maxViewportHeight ? module.maxViewportHeight
],
style: {
'height': module.maxViewportHeight ? 'calc(100vh - var(--nav-bar-height))' : 'auto',
'background-image': module.backgroundImage|length ? 'url("' ~ module.backgroundImage[0].url ~ '")' : 'none',
'background-position': module.backgroundPosition ? module.backgroundPosition : 'unset',
'background-size': module.backgroundSize ? module.backgroundSize : 'unset',
'background-repeat': module.backgroundRepeat ? 'repeat' : 'no-repeat'
}
} %}
{% set attributesSectionLeft = {
class: [
'moduleHalfHalf__section--left',
module.positionThisContentLeft ? 'content--left' : ''
]
} %}
{% set attributesSectionRight = {
class: [
'moduleHalfHalf__section--right',
module.positionThisContentLeft ? '' : 'content--right'
]
} %}
{% set attributesInnerBorder = {
class: [
'moduleHalfHalf__inner-border',
module.borderColor ? module.borderColor : '',
module.positionThisContentLeft ? 'mod--inner-border-right' : 'mod--inner-border-left'
],
style: {
'background-image': module.includeBackgroundInUnderlay ? 'url("' ~ blurredImage.url ~ '")' : 'none',
'background-size': 'cover',
'background-repeat': 'no-repeat'
}
} %}
{% if module.animationType is not empty %}
{% set attributesAnimation1 = {
data: {
'sal': animationType != '' ? animationType : 'fade',
'sal-delay': animationDelay1 ? animationDelay1 : '400',
'sal-easing': animationEasing ? animationEasing : 'ease'
}
} %}
{% set attributesAnimation2 = {
data: {
'sal': animationType != '' ? animationType : 'fade',
'sal-delay': animationDelay2 ? animationDelay2 : '400',
'sal-easing': animationEasing ? animationEasing : 'ease'
}
} %}
{% set attributesAnimation3 = {
data: {
'sal': animationType != '' ? animationType : 'fade',
'sal-delay': animationDelay3 ? animationDelay3 : '400',
'sal-easing': animationEasing ? animationEasing : 'ease'
}
} %}
{% endif %}
<div{{ attr(attributesWrapper) }}>
<div class="moduleHalfHalf__inner-wrapper{% if module.contained %} mod--container{% endif %}{% if module.emphasize %} mod--emphasize{% endif %}">
<div class="moduleHalfHalf__content-wrapper">
<div
{% if not module.positionThisContentLeft %}
{{ attr(attributesSectionLeft) }}
{% if attributesAnimation1 is defined %}
{{ attr(attributesAnimation1) }}
{% endif %}
{% else %}
{{ attr(attributesSectionRight) }}
{% if attributesAnimation1 is defined %}
{{ attr(attributesAnimation1) }}
{% endif %}
{% endif %}>
{% if image is not empty %}
<figure class="moduleHalfHalf__figure">
<img itemprop="image" src="{{ craft.imager.placeholder({ width: 160, height: 90 }) }}" sizes="100vw" srcset="{{ craft.imager.srcset(transformedImages) }}" alt="{{ module.image[0].title }}" class="moduleHalfHalf__img lazy">
</figure>
{% else %}
<div class="content--section">
{% if module.convertToQuote %}
<blockquote class="moduleHalfHalf__blockquote">
<span class="moduleHalfHalf__quotes">
<svg xmlns="http://www.w3.org/2000/svg" width="75.557" height="21.479" viewBox="0 0 75.557 21.479">
<g id="Group_180" data-name="Group 180" transform="translate(876.261 257.401)">
<path id="Path_117" data-name="Path 117" d="M-876.261-245.52c0-8.545,4.565-11.647,11.881-11.881l.878,4.272c-3.921.41-5.853,2.341-5.678,5.5h4.332v11.413h-11.413Zm15.568,0c0-8.545,4.565-11.647,11.822-11.881l.936,4.272c-3.921.41-5.853,2.341-5.736,5.5h4.331v11.413h-11.354Z" fill="rgba(88,89,91,0.2)"/>
<path id="Path_118" data-name="Path 118" d="M-811.623-240.087c3.922-.468,5.853-2.341,5.678-5.5h-4.332V-257h11.413v9.306c0,8.486-4.566,11.647-11.882,11.881Zm15.51,0c3.922-.468,5.853-2.341,5.736-5.5h-4.331V-257h11.354v9.306c0,8.486-4.565,11.647-11.822,11.881Z" transform="translate(-17.35 -0.107)" fill="rgba(88,89,91,0.2)"/>
</g>
</svg>
</span>
{{ module.simpleText2 }}
{% if module.attribution %}
<footer class="moduleHalfHalf__blockquote--footer">
— {{ module.attribution }}
</footer>
{% endif %}
</blockquote>
{% else %}
{{ module.simpleText2 }}
{% endif %}
</div>
{% endif %}
</div>
<div
{% if module.positionThisContentLeft %}
{{ attr(attributesSectionLeft) }}
{% if attributesAnimation2 is defined %}
{{ attr(attributesAnimation2) }}
{% endif %}
{% else %}
{{ attr(attributesSectionRight) }}
{% if attributesAnimation2 is defined %}
{{ attr(attributesAnimation2) }}
{% endif %}
{% endif %}>
<div class="moduleHalfHalf__padding">
{% if module.subheader %}<h4 class="moduleHalfHalf__subheader">{{ module.subheader }}</h4>{% endif %}
<h2 class="moduleHalfHalf__header">{{ module.header }}</h2>
{% if module.simpleText is not empty %}
<div class="moduleHalfHalf__text">
<p>{{ module.simpleText }}</p>
</div>
{% endif %}
{% if module.buttonBuilder|length %}
<div class="button__row">
{% set buttons = module.buttonBuilder.all() %}
{% for button in buttons %}
<a class="{{ button.btnSize}} {{ button.btnType}} mod--a__in-left" href="{% if button.btnInternalLink|length %}{% set buttonLinks = button.btnInternalLink.all() %}{% for buttonLink in buttonLinks %}{{ buttonLink.url }}{% endfor %}{% else %}{{ button.btnExternalLink }}{% endif %}"{% if button.btnBlank %} target="_blank"{% endif %}{% if button.btnNoFollow %} rel="nofollow"{% endif %}>{{ button.btnText }}</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div
{{ attr(attributesInnerBorder) }}
{% if attributesAnimation3 is defined %}
{{ attr(attributesAnimation3) }}
{% endif %}>
<span class="moduleHalfHalf__inner-border-top-cap"></span>
<span class="moduleHalfHalf__inner-border-bottom-cap"></span>
</div>
</div>
</div>
</div>