Skip to content

Building Reusable Blocks for ZeroPoint Starter

Blocks, components, modules, widgets, partials, or whatever you call them are a common way designers and stakeholders expect to visualize websites.

ZeroPoint Starter includes eleventy-plugin-reusable-components preconfigured (and commented out), which makes it easy to create and manage reusable HTML components.

This guide will show you how to install and configure the plugin and create your first reusable block. In the Advanced Usage section, you'll learn how to integrate reusable blocks with a git-based CMS like Decap CMS or Sveltia CMS.

Advantages of Reusable Blocks

Get Started with Reusable Blocks

  1. Install the Plugin

    Install the Eleventy Reusable Components plugin:

    npm install --save-dev eleventy-plugin-reusable-components
  2. Configure the Plugin

    Uncomment the plugin's configuration in src/config/plugins.js:

    import reusableComponents from "eleventy-plugin-reusable-components";
    
    /**
      * ZeroPoint Reusable Components plugin
      * https://github.com/MWDelaney/eleventy-plugin-reusable-components
      */
    async reusableComponents (eleventyConfig) {
      // Add plugin to eleventyConfig
      eleventyConfig.addPlugin(reusableComponents, {
        componentsDir: "src/assets/components/*.njk"
      });
    
      // Register CSS and JS component bundles
      eleventyConfig.addBundle("componentCss", {
        toFileDirectory: "assets/styles/",
      });
    
      eleventyConfig.addBundle("componentJs", {
        toFileDirectory: "assets/scripts/",
      });
    }
  3. Create Your First Component

    Create a new file in the src/assets/components/ directory named text.njk:

    ---
    # A simple text block
    
    # Required fields
    title: Text
    
    # Default Values
    textContent: "Lorem ipsum dolor sit amet, **consectetur adipiscing elit**, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
    
    # CMS fields
    cms:
      - label: "Text Content"
        name: "textContent"
        widget: "markdown"
    ---
    
    <section class="component component-{{ title | slugify }}">
      {{- textContent | markdown | safe -}}
    </section>

    This component has a single field, textContent. The default value is a block of placeholder text.

    Use Your Component

    Open any page on your site and add the following anywhere in the content:

    {{-
      {
        "type": "text",
        "textContent": "Hello, world!"
      } | renderComponent | safe
    -}}

    See the Eleventy Reusable Components documentation for complete usage instructions.

    A More Complicated Example

    Here's a more complex example of a reusable component named call-to-action.njk:

    src/assets/components/call-to-action.njk
    ---
    # A call to action block with optional heading, text, image, and links
    
    # Required fields
    title: Call to Action
    
    # Default Values
    heading: "Join Our Newsletter"
    textContent: "Our newsletter is sent out monthly. We respect your privacy and you can unsubscribe at any time."
    image: "/assets/images/newsletter.png"
    imageAlt: "Newsletter placeholder image"
    links:
      - label: "Subscribe Now"
        url: "/subscribe/"
        style: "primary"
      - label: "Learn More"
        url: "/about/"
        style: "secondary"
    
    # CMS fields
    cms:
      - label: "Heading"
        name: "heading"
        widget: "string"
      - label: "Image"
        name: "image"
        widget: "image"
      - label: "Image Alt Text"
        name: "imageAlt"
        widget: "string"
      - label: "Text Content"
        name: "textContent"
        widget: "markdown"
      - label: "Links"
        name: "links"
        widget: "list"
        fields:
          - label: "Label"
            name: "label"
            widget: "string"
          - label: "URL"
            name: "url"
            widget: "string"
          - label: "Style"
            name: "style"
            widget: "select"
            options:
              - label: Primary
                value: primary
              - label: Secondary
                value: secondary
              - label: Tertiary
                value: tertiary
    ---
    
    <section class="component component-{{ title | slugify }}">
      {%- if heading -%}
      <header>
        <h2>{{ heading }}</h2>
      </header>
      {%- endif -%}
      {%- if image -%}
      <figure>
        <img src="{{ image }}" alt="{{ imageAlt }}">
      </figure>
      {%- endif -%}
      {%- if textContent -%}
      <article class="text-content">
        {{- textContent | markdown | safe -}}
      </article>
      {%- endif -%}
      {%- if links and links.length -%}
      <footer>
        {% for link in links %}
          <a href="{{ link.url }}" class="button button-{{ link.style }}">
            {{ link.label }}
          </a>
        {% endfor %}
      </footer>
    </section>
    
    {%- componentCss %}
      .component-{{ title | slugify }} {
        border: 2px spolid var(--color-primary);
        padding: var(--unit);
      }
    {%- endcomponentCss %}

    To use this component in a page, use the CMS or add the following code to any page:

    content/pages/example-page.njk
    {{-
      {
        "type": "call-to-action",
        "heading": "Join the 11ty Meetup!",
        "textContent": "The official 11ty Meetup is a great way to connect with other Eleventy users, share your projects, and learn new tips and tricks.",
        "image": "/assets/images/meetup.png",
        "imageAlt": "11ty Meetup logo",
        "links": [
          { "label": "Learn More", "url": "https://11tymeetup.dev", "style": "primary" },
          { "label": "Sign Up for Updates", "url": "https://11tymeetup.dev/#newsletter", "style": "secondary" }
        ]
      } | renderComponent | safe
    -}}

    This will render a call-to-action block with a heading, image, text content, and two buttons.

  4. Advanced Usage

    1. Use Components in a CMS

      Eleventy's data cascade makes it easy to integrate reusable components with a CMS like Decap CMS or Sveltia.

      1. Install a CMS

        Follow the Adding a CMS guide.

      2. Generate Editor Components

        To make your reusable components available in the CMS editor, create a new file named content/admin/editor-components.js.jk with the following content:

        content/admin/editor-components.js.njk
        ---
        eleventyExcludeFromCollections: true
        permalink: "/admin/editor-components.js"
        layout: false
        ---
        
        const componentData = [
        {% for component in collections.components %}
          {
            name: "{{ component.data.title | slugify }}",
            label: "{{ component.data.title }}",
            fields: {{ component.data.cms | dump | safe }}
          },
        {% endfor %}
        ];
        
        componentData.forEach(data => {
          window.CMS.registerEditorComponent({
            id: `${data.name}-component`,
            label: data.label,
            fields: data.fields,
            pattern: new RegExp(
              String.raw`^\{\{\-?\s*\{(?=[^}]*\n?"type":\s*"${data.name}")([\s\S]*?)\}\s*\|\s*renderComponent\s*\|\s*safe\s*\-?\}\}$`
            ),
        
            fromBlock: function(match) {
              let props = {};
              try {
                props = JSON.parse('{' + match[1].trim() + '}');
              } catch (e) {
                console.error('Error parsing block JSON:', match[1], e);
                return {};
              }
              return props;
            },
        
            toBlock: function(obj) {
              // Serialize all field values (not just obj.props)
              var json = JSON.stringify(obj, null, 2);
              // Add a line break before the type as the first json object key
              json = json.replace(/^{/, '{\n"type": "' + data.name + '",');
              return '{\{- ' + json + ' | renderComponent | safe -}\}';
            },
        
            toPreview: function(obj) {
              return `<div style='border:1px solid #ccc;padding:1em;'><strong>${data.label}</strong><pre>${JSON.stringify(obj.props, null, 2)}</pre></div>`;
            }
          });
        });

        This file generates a JavaScript array of your reusable components and registers them with the CMS.

      3. Add the Script to the CMS Template

        Add the following line to content/admin/admin.yml:

        content/admin/admin.yml
        <script src="/admin/editor-components.js"></script>

        You can now add your reusable components to any page managed by the CMS!

    2. Bundle Styles and Scripts with Components

      The Reusable Components plugin includes built-in support for component-scoped CSS and JavaScript bundles. This allows you to keep your component styles and scripts organized alongside your component templates.

      How Bundles Work

      When you add componentCss or componentJs blocks to your components, the plugin automatically collects and bundles them. The bundles are then output in your base layout template using Eleventy's getBundle shortcode.

      Include Bundles in Your Layout

      Make sure your base layout includes the bundle output tags. In src/assets/views/layouts/base.njk, add:

      src/assets/views/layouts/base.njk
      <!DOCTYPE html>
      <html lang="{{ lang or 'en' }}">
        <head>
          <!-- Your stylesheets -->
          <link rel="stylesheet" href="{{ '/assets/styles/styles.css' | url }}"/>
      
          <!-- Component CSS bundle -->
          <style>{% getBundle "componentCss" %}</style>
      
          <!-- Other head elements -->
        </head>
        <body>
          <!-- Your content -->
          {{ content | safe }}
      
          <!-- Your scripts -->
          <script src="{{ '/assets/scripts/main.js' | url }}"></script>
      
          <!-- Component JS bundle -->
          <script>{% getBundle "componentJs" %}</script>
        </body>
      </html>

      Component CSS Example

      Add styles to your component using the componentCss block:

      src/assets/components/my-component.njk
      ---
      title: My Component
      textContent: "Sample content"
      ---
      
      {%- componentCss -%}
      .block-{{ title | slugify }} {
        background-color: var(--color-light);
        border: 1px solid var(--color-primary);
        border-radius: var(--border-radius);
        padding: var(--unit);
        margin-bottom: var(--unit);
      
        h2 {
          color: var(--color-primary);
          margin-top: 0;
        }
      
        p {
          line-height: 1.6;
        }
      }
      
      @media (min-width: 768px) {
        .block-{{ title | slugify }} {
          padding: calc(var(--unit) * 2);
        }
      }
      {%- endcomponentCss -%}
      
      <section class="block block-{{ title | slugify }}">
        <h2>{{ heading }}</h2>
        {{- textContent | safe -}}
      </section>

      Component JavaScript Example

      Add JavaScript to your component using the componentJs block:

      src/assets/components/accordion.njk
      ---
      title: Accordion
      heading: Frequently Asked Questions
      items:
        - question: "What is ZeroPoint?"
          answer: "ZeroPoint is a starter template for Eleventy."
        - question: "How do I use components?"
          answer: "Follow this guide to create reusable components."
      ---
      
      {%- componentJs -%}
      document.addEventListener('DOMContentLoaded', function() {
        const accordions = document.querySelectorAll('.block-accordion');
      
        accordions.forEach(accordion => {
          const buttons = accordion.querySelectorAll('.accordion-button');
      
          buttons.forEach(button => {
            button.addEventListener('click', function() {
              const content = this.nextElementSibling;
              const isOpen = content.style.maxHeight;
      
              // Close all items
              accordion.querySelectorAll('.accordion-content').forEach(item => {
                item.style.maxHeight = null;
                item.previousElementSibling.setAttribute('aria-expanded', 'false');
              });
      
              // Open clicked item if it was closed
              if (!isOpen) {
                content.style.maxHeight = content.scrollHeight + 'px';
                this.setAttribute('aria-expanded', 'true');
              }
            });
          });
        });
      });
      {%- endcomponentJs -%}
      
      {%- componentCss -%}
      .block-accordion {
        .accordion-item {
          border: 1px solid var(--color-light);
          margin-bottom: var(--unit-small);
        }
      
        .accordion-button {
          width: 100%;
          padding: var(--unit);
          background: var(--color-light);
          border: none;
          text-align: left;
          cursor: pointer;
          font-weight: bold;
      
          &:hover {
            background: var(--color-primary-light);
          }
        }
      
        .accordion-content {
          max-height: 0;
          overflow: hidden;
          transition: max-height 0.3s ease;
          padding: 0 var(--unit);
        }
      }
      {%- endcomponentCss -%}
      
      <section class="block block-{{ title | slugify }}">
        <h2>{{ heading }}</h2>
        {% for item in items %}
          <div class="accordion-item">
            <button class="accordion-button" aria-expanded="false">
              {{ item.question }}
            </button>
            <div class="accordion-content">
              <p>{{ item.answer }}</p>
            </div>
          </div>
        {% endfor %}
      </section>