This article explores how to create a reusable and flexible field for managing section layouts in Payload CMS. The approach aims to make layout configuration straightforward for developers and user-friendly for content managers.
If you only want to check the code: field in Payload, frontend implementation, GitHub.
Don't be confused
I use Payload 3.0, but you can use previous versions.The part related to managing section paddings will be the same.
Creating a reusable field
If you are familiar with Payload, you know that blocks are used for sections here. This is a powerful tool that allows you to perform validation on the frontend, make some fields available or visible at certain values of other fields, use your own components, and much more. More information can be found in the docs.
The simplest implementation of a universal section padding control would be to create and use such a const.
012345678910111213141516171819202122
import { Field } from 'payload';
export const SECTION_LAYOUT: Field = {
type: 'group',
name: 'sectionLayout',
label: 'Section layout',
fields: [
{
type: 'number',
name: 'paddingTop',
label: 'Padding top',
defaultValue: 24,
required: true,
},
{
type: 'number',
name: 'paddingBottom',
label: 'Padding Bottom',
defaultValue: 24,
required: true,
},
],
};
01234567891011121314151617181920212223242526272829303132333435
// SECTION_LAYOUT usage
import type { Block } from 'payload';
import { SECTION_LAYOUT } from '@/payload/fields/sectionLayout';
export const Hero: Block = {
imageURL: '/api/media/file/Hero.png',
slug: 'hero',
labels: {
singular: 'Hero',
plural: 'Heroes',
},
interfaceName: 'Hero',
fields: [
{
type: 'upload',
name: 'image',
label: 'Image',
relationTo: 'media',
required: true,
},
{
type: 'text',
name: 'heading',
label: 'Heading',
required: true,
},
{
type: 'text',
name: 'subheading',
label: 'Subheading',
},
SECTION_LAYOUT, // Adding setting from previous file
],
};
So, we created constant SECTION_LAYOUT
,which is Field with type group
and fields paddingTop
and paddingBottom
. As simple as it can be. The group
type was chosen because it adds a field to the schema as opposed to collapsible
, which just unites fields into a collapsible group on the frontend part of the admin panel. Also group
allows to work more conveniently with types.
This is how it looks like in admin panel:
Adding breakpoints
The next step is adding the ability to add breakpoints. This is necessary to build responsive web sites. I like the idea of adding a pre-populated breakpoint and the ability to add your own breakpoints at the same time. This will make filling in content both easy and flexible.
01234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
import { Field, NumberFieldSingleValidation } from 'payload';
const minValueValidator: NumberFieldSingleValidation = (value: number | null | undefined) =>
Number(value) >= 0 ? true : 'Value must be equal or greater than 0';
export const SECTION_LAYOUT: Field = {
type: 'group',
name: 'sectionLayout',
label: 'Section layout',
fields: [
{
type: 'number',
name: 'paddingTop',
label: 'Padding top',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingBottom',
label: 'Padding Bottom',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'array',
name: 'breakpoints',
label: 'Breakpoints',
fields: [
{
type: 'number',
name: 'minWidth',
label: 'Min width',
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingTop',
label: 'Padding top',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingBottom',
label: 'Padding Bottom',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
],
defaultValue: [
{
minWidth: 767,
paddingTop: 48,
paddingBottom: 48,
},
],
},
],
};
So, now 1 breakpoint for min width 767 is added by default, and the user can add more breakpoints if needed. Also, now all fields are required, and the user can't add negative values. In such a case, he will see such an error:
Final touches
The current result can already be used on live projects, but I would prefer to add a few changes:
- Add units to all settings. (It is obvious that these are pixels, but just because it is obvious to a developer doesn't mean it will be obvious to a content manager 🙂).
- Add the ability to override
defaultValues
for specific sections (e.g. the Hero section is most often first, and it might be a good idea to remove the top default paddings). It's not a big deal, but why not? - Add a generated type to use on the frontent part. This will make it easier to develop a component to render the section.
- Add a “Hide section” checkbox - this has nothing to do with managing paddings, but since we are already adding global settings to sections, it makes sense to leave this checkbox here. The idea of this customization is inspired by Shopify. The concept is that by checking this checkbox, the section should not be displayed, but it is still stored in the database, so it can be returned in 1 click. This is useful when you need to temporarily hide a section and then return it.
So, after adding all these features we get this code:
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
import { Field, NumberFieldSingleValidation } from 'payload';
import { SectionLayout } from '@/payload-types';
const minValueValidator: NumberFieldSingleValidation = (value: number | null | undefined) =>
Number(value) >= 0 ? true : 'Value must be equal or greater than 0';
const rewriteFieldDefaults = (
fields: Field[],
overrideDefaults?: Partial<SectionLayout>,
): Field[] => {
if (!overrideDefaults) {
return fields;
}
return fields.map((field) => {
if (!('name' in field)) {
return field;
}
const defaultValueFromOverrides = overrideDefaults[field.name as keyof SectionLayout];
if (defaultValueFromOverrides !== undefined) {
if (field.type !== 'ui') {
field.defaultValue = defaultValueFromOverrides;
}
}
return field;
});
};
export const SECTION_LAYOUT = (overrideDefaults?: Partial<SectionLayout>): Field => {
const fields: Field[] = [
{
type: 'checkbox',
name: 'hideSection',
label: 'Hide section',
defaultValue: false,
},
{
type: 'number',
name: 'paddingTop',
label: 'Padding top, px',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingBottom',
label: 'Padding Bottom, px',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'array',
name: 'breakpoints',
label: 'Breakpoints',
fields: [
{
type: 'number',
name: 'minWidth',
label: 'Min width, px',
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingTop',
label: 'Padding top, px',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
{
type: 'number',
name: 'paddingBottom',
label: 'Padding Bottom, px',
defaultValue: 24,
required: true,
validate: minValueValidator,
},
],
defaultValue: [
{
minWidth: 767,
paddingTop: 48,
paddingBottom: 48,
},
],
},
];
return {
type: 'group',
name: 'sectionLayout',
label: 'Section layout',
interfaceName: 'sectionLayout',
fields: rewriteFieldDefaults(fields, overrideDefaults),
};
};
012345678910111213141516171819202122232425262728293031323334353637383940414243
//SECTION_LAYOUT usage with overriding the default values
import type { Block } from 'payload';
import { SECTION_LAYOUT } from '@/payload/fields/sectionLayout';
export const Hero: Block = {
imageURL: '/api/media/file/Hero.png',
slug: 'hero',
labels: {
singular: 'Hero',
plural: 'Heroes',
},
interfaceName: 'Hero',
fields: [
{
type: 'upload',
name: 'image',
label: 'Image',
relationTo: 'media',
required: true,
},
{
type: 'text',
name: 'heading',
label: 'Heading',
required: true,
},
{
type: 'text',
name: 'subheading',
label: 'Subheading',
},
SECTION_LAYOUT({
paddingTop: 0,
breakpoints: [
{
minWidth: 767,
paddingTop: 0,
paddingBottom: 48,
},
],
}),
],
};
012345678910111213141516
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "sectionLayout".
*/
export interface SectionLayout {
hideSection?: boolean | null;
paddingTop: number;
paddingBottom: number;
breakpoints?:
| {
minWidth: number;
paddingTop: number;
paddingBottom: number;
id?: string | null;
}[]
| null;
}
Adding units is the easiest part of the change, it's basically just changing the label
. Adding the “Hide section” checkbox I think it's obvious, and we don't have to stop there.
I added a part of payload-types.ts
with an autogenerated SectionLayout
type to make it clear what we get in the output. The type is generated because of the added setting interfaceName
.
The most global change is the ability to override defaultValues
for specific sections. To do this, const SECTION_LAYOUT
has been changed to a function that can accept overrideDefaults
. You can find out the structure of this parameter by looking at the generated type on tab 3. By the way, this is another place where the interfaceName
setting comes in handy.
In the rewriteFieldDefaults
function, you may be confused by such lines as if ((!('name' in field))) { return field; }
and if (field.type !== 'ui') {...}
. field
may not have a name
for certain types, such as collapsible
. Such fields are not transformed into values in the database, but just improve the UI in admin panel. The same applies to a field with type ui
- it cannot have a defaultValue
, and therefore there is no need to overwrite it.
So, the final view of the newly added Hero section (see Table 2) looks like this:
Frontend implementation
Now, the only thing left is to implement the frontend part. I will do it using React component since using Next makes working with Payload much more convenient and simple since Payload 3. You can implement using the technology your site uses.
The idea is to create a Section
component that will take the settings created earlier and render the section with the selected paddings on the selected breakpoints. Or render nothing if the “Hide section” checkbox is checked.
01234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
import { ComponentProps, useId } from 'react';
import { SectionLayout } from '@/payload-types';
type SectionProps = SectionLayout & ComponentProps<'section'>;
const generateMediaQuery = (
id: string,
breakpoint: NonNullable<SectionLayout['breakpoints']>[number],
): string => {
if (!breakpoint) {
return '';
}
const { minWidth, paddingTop, paddingBottom } = breakpoint;
return `
@media screen and (min-width: ${minWidth}px) {
[id="${id}"] {
padding-top: ${paddingTop}px;
padding-bottom: ${paddingBottom}px;
}
}
`;
};
export const Section = ({
hideSection = false,
paddingTop,
paddingBottom,
breakpoints,
children,
...rest
}: SectionProps) => {
if (hideSection) {
return null;
}
const id = rest.id ?? useId();
const initialStyles = `
[id="${id}"] {
padding-top: ${paddingTop}px;
padding-bottom: ${paddingBottom}px;
}
`;
const styles =
initialStyles +
(breakpoints
? breakpoints.map((breakpoint) => generateMediaQuery(id, breakpoint)).join('\n')
: '');
return (
<section {...rest} id={id}>
<style>{styles}</style>
{children}
</section>
);
};
012345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
import React from 'react';
import { Media } from '@/components/Media';
import { Section } from '@/components/Section';
import { SectionHeading } from '@/components/SectionHeading';
import { Hero as HeroType } from '@/payload-types';
import { cn } from '@/utils/cn';
import { Block } from '@/utils/types';
export type HeroProps = Block<HeroType>;
export const Hero = ({ image, heading, subheading, isFirst, sectionLayout }: HeroProps) => {
const loading = isFirst ? 'eager' : 'lazy';
return (
<Section {...sectionLayout}>
<div className="container">
<div
className={cn(
'max-w-[500px] mx-auto flex flex-col gap-4',
'sm:max-w-full sm:flex-row sm:gap-8 sm:items-center',
'md:gap-14',
)}
>
{image && (
<div className="sm:w-[calc(50%-16px)]">
<Media
className="w-full rounded-full my-0"
sizes="(min-width: 768px) 50vw, (min-width: 1440px) 692px, 100vw"
source={image}
width={414}
height={414}
loading={loading}
/>
</div>
)}
{Boolean(heading || subheading) && (
<div className={cn('text-center', 'sm:text-left sm:w-[calc(50%-16px)]')}>
{heading && (
<SectionHeading isFirst={isFirst} className={cn('h1 mb-4', 'sm:md-8 sm:text-left')}>
{heading}
</SectionHeading>
)}
{subheading && (
<p className={cn('m-0 text-xl', 'sm:text-2xl', 'md:text-4xl')}>{subheading}</p>
)}
</div>
)}
</div>
</div>
</Section>
);
};
So, let's start with the Section
component. It accepts SectionLayout
type settings as prop (described in the article settings) and also ComponentProps<'section'>
- React props for <section>
tag such as className, id, style, etc. They also include children.
If “Hide section” checkbox is checked - return null
, we don't show the section. I think this part is very clear.
In order to add styles for the section, the <style>
tag is used. We can't use props style because we use breakpoints.
The id is used as a selector because it must be unique. React hook useId
is used for generating unique IDs if id has not been passed as prop. This is done in case we need to have a specific id. For example, a section should have an anchor link and the id field is added to settings. Why is the id selector used as an attribute ([id=“${id}”]
) instead of the more primitive number sign (#${id}
)? The point is what unique ID is generated by useId
. In my case it was :S1: - CSS sees ":" as a selector (e.g. :first-child
), so the #:S1:
selector is not correct and will be ignored.
The second tab shows the usage of Section
- everything is simple enough you just need to pass sectionLayout
from the block settings.
Conclusion
In this article, we built a reusable Payload field and a React-based frontend implementation for managing section layouts. This solution provides features like section visibility toggles, customizable paddings with breakpoints, and override options for default values, all while maintaining scalability and ease of use.
Video demonstration of changing paddings in admin panels from Live Preview:
All the code from the article is available on GitHub. Feel free to use.