DEV Community

Cover image for Let’s Get Hands-On with WordPress FSE Theme and Custom Blocks — Part 1
Silvia Malavasi
Silvia Malavasi

Posted on • Updated on

Let’s Get Hands-On with WordPress FSE Theme and Custom Blocks — Part 1

FSE (Full Site Editing): Blessing and Curse

The introduction of Full Site Editing (FSE) represents one of the most significant evolutions in WordPress history. The aim is to provide users with a visual builder-like experience, allowing them to see a direct preview in the dashboard of what will happen on the frontend.

Since the beginning, the evolution of WordPress has pursued this need: to bridge the gap between the complexity of the CMS and the goal of creating a system where a moderately tech-savvy user can independently build a site suited to their needs, including the visual aspect.

Like any evolutionary process, this journey has seen its share of rapid transformations. Sometimes, developers have found themselves facing an enormous change that took time to digest. With FSE and Block Themes in particular, the change has been radical:

  • No more PHP in the theme root.
  • Strange HTML filled with comments that aren’t comments.
  • A configuration file in JSON format.

For an introduction to these topics, WordPress Developer Resources is a good starting point. Here, the structure of the new themes is explained.

A major innovation is the management of page parts. The header and footer can now be edited by users through a dedicated editor in the dashboard. Users can also visually manage templates in this editor, adding core blocks to each page template, including Query Loops. Templates and Parts are saved in the database but can be set via code using the strange HTML mentioned earlier.

In terms of styling, an FSE theme maintains its own style.css, while global styles for typography, colors, padding, and other site-wide elements are declared in the theme.json file. These styles are accessible to users in the sidebar of the selected block.

With these changes alone, it’s evident that the level of user control over the site is infinitely greater than in a traditional theme. Evolution has certainly taken place. However, as developers, we’re not just users; we want to delve deep into the possibilities that the Block Themes system offers.

A notable innovation is an integrated build process in FSE. Do we want to add JavaScript in ES6? Do we want to use SASS? WordPress provides the WordPress scripts package (built on top of webpack) to compile and include our files.

Block Editor: It Gets Even Better

Block Themes allow the integration of custom Blocks that can be used anywhere in the theme. I’m not talking about Patterns, which are essentially simplified versions of a Block designed to create a reusable graphic template (somewhat like the old reusable blocks). We’re talking about entities that use React and require a build process.

The anatomy of a block

A block consists of several components. Some are for declaring the block, others for how the block behaves in the dashboard, and others for the block’s display on the frontend.

  • index.js is our entry point and initializes the block
  • block.json declares the block properties, including custom attributes or additional JavaScript to be run on the frontend
  • edit.js and edit.scss (or css) handle the dashboard-related part
  • save.js and save.scss (or css) handle the frontend-related part

Additional JavaScript or PHP files can be incorporated for specific functionalities, such as animating elements using GSAP or adjusting server-side rendering for the block.

To use these blocks, they need to be registered in our functions.php file.

Example block: gallery with SwiperJS

Now, finally, some code. This block is moderately complex but perfect for explaining the integration between PHP and React. We will create a gallery with a management interface on the dashboard side. We’ll use SwiperJS library for the gallery’s JavaScript and import it into our block. The build process will be explained in the second part of the article because it differs somewhat from the standard process.

Let’s start with index.js:

import { registerBlockType } from "@wordpress/blocks";
import "./edit.scss";
import "./save.scss";
import Edit from "./edit";
import save from "./save";
import metadata from "./block.json";
registerBlockType(metadata.name, {
  edit: Edit,
  save,
});
Enter fullscreen mode Exit fullscreen mode

Nothing more than an entry point for the WordPress scripts package build system.

In block.json, we start to see something more interesting

{
  "apiVersion": 2,
  "name": "blocks/gallery",
  "title": "Gallery",
  "version": "1.0.0",
  "category": "custom-blocks",
  "icon": "format-gallery",
  "description": "A block with a gallery on the left, and text on the right.",
  "supports": {
    "html": false,
  },
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./index.css",
  "viewScript": "file:./gallery.js",
  "attributes": {
    "slideCount": {
      "type": "number",
      "default": 1
    }
  },
  "example": {
    "innerBlocks": [
      {
        "name": "core/image",
        "attributes": {
          "url": "http://localhost/site/wp-content/themes/my-theme/blocks/custom-blocks/gallery/gallery.png",
          "alt": "gallery preview"
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

In addition to the standard declarations, we have stated that we will use the gallery.js file on the frontend.

"viewScript": "file:./gallery.js",
Enter fullscreen mode Exit fullscreen mode

And we have declared a new attribute called slideCount

"attributes": {
    "slideCount": {
      "type": "number",
      "default": 1
    }
  },
Enter fullscreen mode Exit fullscreen mode

which we will use. We have also included a preview image to replace the standard preview system. This part is optional.


"example": {
    "innerBlocks": [
      {
        "name": "core/image",
        "attributes": {
          "url": "http://localhost/site/wp-content/themes/my-theme/blocks/custom-blocks/gallery/gallery.png",
          "alt": "gallery preview"
        }
      }
    ]
  }
Enter fullscreen mode Exit fullscreen mode

Alright, now React comes into play. Let’s look at edit.js It’s a bit long, but if you prefer, you can skip directly to the explanations below the code.

import { Button } from "@wordpress/components";
import { useDispatch, useSelect } from "@wordpress/data";
import { InnerBlocks, useBlockProps } from "@wordpress/block-editor";
import { useEffect, useRef } from "@wordpress/element";
import Swiper from "swiper";
import { Navigation, Pagination } from "swiper/modules";

export default function Edit({ attributes, setAttributes, className, clientId }) {
  const ref = useRef();
  const swiperRef = useRef();
  const blockProps = useBlockProps({ ref });

  const { blockOrder, rootClientId, areBlocksInserted } = useSelect(
    (select) => {
      const { getBlockOrder, getBlockHierarchyRootClientId } = select("core/block-editor");
      const blockOrder = getBlockOrder(clientId);
      const rootClientId = getBlockHierarchyRootClientId(clientId);
      return {
        blockOrder,
        rootClientId,
        areBlocksInserted: blockOrder.length === blockOrder.length,
      };
    },
    [clientId]
  );

  const { insertBlock, removeBlock } = useDispatch("core/block-editor");

  useEffect(() => {
    if (
      areBlocksInserted &&
      ref.current &&
      !ref.current.querySelector(".block-editor-inner-blocks").classList.contains("swiper-container-initialized")
    ) {
      let swiperElement = ref.current.querySelector(".block-editor-inner-blocks");
      let swiperWrapper = ref.current.querySelector(".block-editor-block-list__layout");
      swiperElement.classList.add("swiper");
      swiperWrapper.classList.add("swiper-wrapper");
      let swiper_pagination = ref.current.querySelector(".swiper-pagination");
      let swiper_prev = ref.current.querySelector(".swiper-prev");
      let swiper_next = ref.current.querySelector(".swiper-next");

      swiperRef.current = new Swiper(swiperElement, {
        modules: [Navigation, Pagination],
        observer: true,
        observeParents: true,
        pagination: {
          el: swiper_pagination,
          clickable: true,
        },
        navigation: {
          nextEl: swiper_next,
          prevEl: swiper_prev,
        },
        slidesPerView: 1,
        speed: 800,
        touchStartPreventDefault: false,
      });
    }
  }, [areBlocksInserted, ref.current]);

  const TEMPLATE = [
    [
      "core/columns",
      { className: "gallery-cont swiper-slide" },
      [
        ["core/column", {}, [["core/image", {}]]],
        [
          "core/column",
          {},
          [
            ["core/heading", { placeholder: "Insert title", level: 2 }],
            ["core/heading", { placeholder: "Insert small title", level: 3 }],
            ["core/paragraph", { placeholder: "Insert content" }],
          ],
        ],
      ],
    ],
  ];

  const addSlide = () => {
    const newSlideCount = attributes.slideCount + 1;
    setAttributes({ slideCount: newSlideCount });
    const slideBlock = wp.blocks.createBlock(
      "core/columns",
      { className: `gallery-cont swiper-slide slide-${attributes.slideCount}` },
      [
        wp.blocks.createBlock("core/column", {}, [wp.blocks.createBlock("core/image", {})]),
        wp.blocks.createBlock("core/column", {}, [
          wp.blocks.createBlock("core/heading", { placeholder: "Insert title", level: 2 }),
          wp.blocks.createBlock("core/heading", { placeholder: "Insert small title", level: 3 }),
          wp.blocks.createBlock("core/paragraph", { placeholder: "Insert content" }),
        ]),
      ]
    );
    insertBlock(slideBlock, blockOrder.length, clientId);
    swiperRef.current.update();
    swiperRef.current.slideTo(attributes.slideCount);

    setTimeout(() => {
      swiperRef.current.slideTo(attributes.slideCount);
    }, 300);
  };

  const removeSlide = () => {
    const newSlideCount = attributes.slideCount - 1;
    setAttributes({ slideCount: newSlideCount });
    if (swiperRef.current) {
      const activeIndex = swiperRef.current.activeIndex;
      const blockToRemove = blockOrder[activeIndex];
      removeBlock(blockToRemove, rootClientId);
      swiperRef.current.update();
    }
  };

  return (
    <div
      {...blockProps}
      className={`${className || ""} my-block gallery edit`}
    >
      <div className="swiper">
        <div className="swiper-wrapper">
          <InnerBlocks
            template={TEMPLATE}
            templateInsertUpdatesSelection={false}
            templateLock={false}
          />
        </div>
      </div>
      <div className="swiper-pagination"></div>
      <div
        className="swiper-navigation"
        style={{ height: attributes.slideCount === 1 ? "0" : "50px" }}
      >
        <div className="swiper-prev"></div>
        <div className="swiper-next"></div>
      </div>
      <div className="button-wrapper">
        <Button
          isSecondary
          onClick={addSlide}
        >
          Add a Slide
        </Button>
        <Button
          isSecondary
          onClick={removeSlide}
          style={{ display: attributes.slideCount > 1 ? "inline-flex" : "none" }}
        >
          Remove Current Slide
        </Button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

First, let’s import some built-in WordPress functions

import { Button } from "@wordpress/components";
import { useDispatch, useSelect } from "@wordpress/data";
import { InnerBlocks, useBlockProps } from "@wordpress/block-editor";
import { useEffect, useRef } from "@wordpress/element";
import Swiper from "swiper";
import { Navigation, Pagination } from "swiper/modules";
Enter fullscreen mode Exit fullscreen mode

Some of these are familiar to those who use React. Here we import a version of those functions that is usable in the editor.

As attributes of Edit function, we use, among others, our custom attribute that we declared in block.json

export default function Edit({ attributes, setAttributes, className, clientId }) {

Enter fullscreen mode Exit fullscreen mode

Then we set up Refs in the React way and use useDispatch and useSelect to manage the dynamic data flow from the server.

Inside useEffect, we handle SwiperJS. This is intriguing because we are launching a gallery that will be displayed directly in the editor. If we optimize the block interface effectively, our user will see the gallery almost exactly as it will appear to site visitors (on the frontend). Of course, in the editor, we’ll also include buttons to add more slides, which won’t be visible to visitors. This marks a notable shift in perspective.

Now, what does TEMPLATE refer to?

const TEMPLATE = [
    [
      "core/columns",
      { className: "gallery-cont swiper-slide" },
      [
        ["core/column", {}, [["core/image", {}]]],
        [
          "core/column",
          {},
          [
            ["core/heading", { placeholder: "Insert title", level: 2 }],
            ["core/heading", { placeholder: "Insert small title", level: 3 }],
            ["core/paragraph", { placeholder: "Insert content" }],
          ],
        ],
      ],
    ],
  ];
Enter fullscreen mode Exit fullscreen mode

It’s a part of InnerBlocks, a very handy system for importing elements already present in WordPress into our custom block, such as images (including the media library handling) and text elements. Optionally, we can insert videos, quotes, and many other blocks, each with their integrated management system. This syntax closely resembles what we find in the strange HTML of FSE themes, akin to what we see when copying a block and pasting it into a text file.

The template will be rendered with minimal code in save.js, while here it is invoked within InnerBlocks in our return statement (yes, exactly, we're in React logic).

The functions addSlide and removeSlide manage the insertion of new slides. The slide count is managed through the attribute we added. Why isn't it an internal variable? Because we need the slide count to be stored in the database, and that's exactly what custom attributes do. We use createBlock, insertBlock, and removeBlock to insert and remove the slides, which are structured exactly like our initial TEMPLATE. Let’s take a look:

const slideBlock = wp.blocks.createBlock(
      "core/columns",
      { className: `gallery-cont swiper-slide slide-${attributes.slideCount}` },
      [
        wp.blocks.createBlock("core/column", {}, [wp.blocks.createBlock("core/image", {})]),
        wp.blocks.createBlock("core/column", {}, [
          wp.blocks.createBlock("core/heading", { placeholder: "Insert title", level: 2 }),
          wp.blocks.createBlock("core/heading", { placeholder: "Insert small title", level: 3 }),
          wp.blocks.createBlock("core/paragraph", { placeholder: "Insert content" }),
        ]),
      ]
    );
Enter fullscreen mode Exit fullscreen mode

In the end, our render function mirrors the HTML structure needed by SwiperJS, with our TEMPLATE inserted as the first slide.

<InnerBlocks
  template={TEMPLATE}
  templateInsertUpdatesSelection={false}
  templateLock={false}
/>
Enter fullscreen mode Exit fullscreen mode

And we include buttons for inserting or removing slides:

<Button
  isSecondary
  onClick={addSlide}
  >
  Add a Slide
</Button>
<Button
  isSecondary
  onClick={removeSlide}
  style={{ display: attributes.slideCount > 1 ? "inline-flex" : "none" }}
>
  Remmove Current Slide
</Button>
Enter fullscreen mode Exit fullscreen mode

The frontend part is much simpler. The file responsible for rendering our block on the site is save.js

import { InnerBlocks, useBlockProps } from "@wordpress/block-editor";

export default function Save({ className }) {
  const blockProps = useBlockProps.save();

  return (
    <div
      {...blockProps}
      className={`${className || ""} my-block gallery`}
    >
      <div className="swiper">
        <div className="swiper-wrapper">
          <InnerBlocks.Content />
        </div>
      </div>
      <div className="swiper-pagination"></div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We import InnerBlocks to display the content of our InnerBlocks as defined in edit.js.

Finally, we add the JavaScript code to launch the gallery with SwiperJS on the frontend in gallery.js.

import Swiper from "swiper";
import { Pagination } from "swiper/modules";

function gallery() {
  document.querySelectorAll(".my-block.gallery").forEach(function (el) {
    var swiper_pagination = el.querySelector(".swiper-pagination");

    var swiper = new Swiper(el.querySelector(".swiper"), {
      modules: [Pagination],
      pagination: {
        el: swiper_pagination,
        clickable: true,
      },
      slidesPerView: 1,
      speed: 800,
    });
  });
}

document.addEventListener("DOMContentLoaded", gallery);
Enter fullscreen mode Exit fullscreen mode

Create your own Blocks

Creating your own blocks following this structure is straightforward at this point. Here, I’ve shown a block with a dynamic interface for managing slides, but you can apply the same method to create blocks that are simpler or much more complex. What remains to be defined is the folder structure of the project (whether it’s a theme or a plugin) and, of course, the build process. This will be the topic of the second part of the article.

Another function we can employ when we want to fetch dynamic data is render_callback, which we pass as an argument when registering the block in functions.php. render_callback allows us to retrieve a list of posts, an archive, or navigation menus directly from the server. For instance, in my ZenFSE theme, I've developed a custom header that allows users to choose between two different menus—one for desktop and one for mobile. These menus are fetched using render_callback.

Even more: Modifying Core Blocks

Now that we understand how a block works, keep in mind that WordPress Core Blocks operate in the same way (but with many more features). If you want to take a look at the source code of the blocks, you can go to the WordPress Repository on GitHub.

All blocks, including Core blocks, can be customized using hooks. In particular, we’re interested in addFilter. With this hook, for example, we can enhance an existing block’s functionality without having to rewrite it from scratch. For instance, we could add an additional text field to an image block, or add a dropdown in the block sidebar for selecting an animation to execute when the block is rendered. In ZenFSE, I've used GSAP to add an entrance animation to all my blocks.

Conclusion — Part One

Conclusion — Part One
This level of interactivity with components truly leaves ample room for creativity and imagination, both from the developer and the user perspectives. Another advantage is the modular nature of the code. Each block resides within its own folder with its files, and in terms of dependencies, each block has its own CSS and JavaScript. Blocks can be used anywhere on the site and can also be inserted into other blocks (such as groups or columns). Moreover, InnerBlocks inherit the properties of the blocks they are composed of, so it’s possible to apply colors, typography, and all globally declared properties from the theme.json file.

WordPress continues to evolve with new solutions, the latest of which, at the moment I am writing, is the Interactivity API, which allows defining block interactions in an even more immediate manner.

There is no rest for developers.

Top comments (0)