Visual Editor /Visual editing /

Custom fields

Custom fields provide the ability to extend the default set of field types and controls available in the page and content editors.

For example, suppose you want to add a field to the page editor that allows the user to select an emoji. Instead of creating an enum field and presenting emojis in a dropdown menu or thumbnail images, you can create a custom field type that renders the emojis directly.

Custom inline HTML control.

# Types of custom fields

There are two types of custom fields:

  • Inline HTML Fields: Controls that are rendered directly in the form.
  • Modal HTML Fields: The form presents an "Open" button, from which a modal window opens with the custom control.

# Inline HTML fields

Inline HTML fields are rendered directly in the form. They are useful for simple controls that do not require a lot of space or that resemble standard form controls.

Custom inline HTML control.

Modal HTML fields appear as an Open button within the form.

Custom modal control, open button.

Clicking this button opens a modal in which the HTML form control is rendered. Visual Editor automatically includes the label and functionality for closing the modal, although this can be customized.

Custom modal control, modal window.

These types of controls are useful for more complex controls that require more space or that would typically be used within a dialog window.

# How custom fields work

Custom fields are built as HTML files, presented in Visual Editor through an iframe. The HTML file brings its own styling and uses JavaScript to hook into the editor and data flow.

# Field types vs control types

Custom fields can be defined against any Visual Editor field type by specifying the control type and a path to the HTML file.

The custom field should then set the value of the field to match the expected format of the field type.

// stackbit.config.ts
import { ObjectModel } from "@stackbit/types";

export const HeroSection: ObjectModel = {
  name: "HeroSection",
  type: "object",
  fields: [
    {
      type: "string",
      name: "title",
      label: "Heading"
    },
    {
      type: "string",
      name: "emoji",
      controlType: "custom-inline-html",
      controlFilePath: ".stackbit/fields/emoji.html"
    }
  ]
};

In the example above, the emoji field is defined as a string field type, but the controlType is set to custom-inline-html. This field is presented as an iframe using the contents of .stackbit/fields/emoji.html (relative to the project root).

See the configuration reference for usage of controlType and controlFilePath.

# Hook into the editor

When Visual Editor first loads, it looks for custom fields and injects a control.js script into the HTML file. This controls when the script defined in the HTML file (noted below) fires.

The HTML file should include a script that hooks into the editor's data flow. When the field first appears, Visual Editor looks to call a global stackbit.onUpdate() function with an object of useful properties.

On the initial call, the init property is set to true, which can be used as a signal to attach event listeners to elements within the custom field.

Using the emoji example, each emoji could be presented as a button, and would update the field's value when the button is clicked.

<script>
  window.stackbit = window.stackbit || {};

  window.stackbit.onUpdate = options => {
    if (options.init) {
      const buttons = document.querySelectorAll("button");
      buttons.forEach(button =>
        button.addEventListener("click", event => {
          // Update the field's value ...
        })
      );
    }
  };
</script>

Tip

It is safest to manually initialize the window.stackbit object, as it may not be available when the script is executed.

See below for a more complete example.

# Best practices

This section provides some common practices for working with custom fields, before moving on to an example.

# HTML file location

The HTML file can be placed anywhere in the project. Because Visual Editor plays no role in production, we recommend that this be placed in a directory that isn’t published to the web and that doesn’t interfere with your application.

In the examples shared in this doc, we’re using a .stackbit directory in the project root. This is typically where we recommend putting Visual Editor-specific files.

# Add styles

In most cases, a custom field is going to want to bring its own style for an enhanced editing experience. The best way to do this is either to write styles directly in the HTML file or to link to an external CSS file. See the next section for an example.

# Use external assets

The HTML file can import CSS and JavaScript files from anywhere in the project repository. However, it's often easiest to place it in the same directory as the HTML file.

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- ... -->
    <link rel="stylesheet" href="emoji.css" />
  </head>
  <body>
    <!-- ... -->

    <script src="emoji.js"></script>
  </body>
</html>

# Avoid public directories

Because sites typically don’t use Visual Editor in production, we recommend that you avoid placing custom field files in directories that are published to the web.

While you could remove these files during the build, it’s best to avoid the risk of accidentally publishing them since there’s no benefit to doing so, and may be risky if the files contain sensitive information.

# Emoji field example

This example shows how to create a custom emoji field on a hero component. The field enables editors to select from a set of five emojis. The stored values is a string of descriptive text representing the emoji. (This value would then be used to map to the emoji on the front end.)

Note

All of these files would be contained in a .stackbit/fields directory.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Emoji Field</title>
    <link rel="stylesheet" href="emoji.css" />
  </head>
  <body>
    <div class="grid">
      <button data-value="grinning">😃</button>
      <button data-value="slightly_smiling_face">🙂</button>
      <button data-value="neutral_face">😐</button>
      <button data-value="slightly_frowning_face">🙁</button>
      <button data-value="frowning_face">☹️</button>
    </div>

    <script src="emoji.js"></script>
  </body>
</html>
body {
  max-width: 100%;
}

button {
  font-size: 2rem;
  padding: 0.5rem;
  background: none;
  cursor: pointer;
  border: 1px solid #cfcfcf;
  border-radius: 0.25rem;
  line-height: 1;
  transition: background 0.3s ease;
}

button.active {
  border-color: teal;
}

button:hover {
  background: #efefef;
}

.grid {
  display: flex;
  gap: 1rem;
}
// Safely initialize the stackbit object
window.stackbit = window.stackbit || {};

window.stackbit.onUpdate = options => {
  // Helper function to get the current value of the field. This is most useful
  // when a document has nested content fields.
  function getCurrentValue() {
    try {
      return options.fieldPath.reduce((acc, curr) => {
        const newValue = acc[curr];
        if ("items" in newValue) return newValue.items;
        if ("fields" in newValue) return newValue.fields;
        return newValue;
      }, options.document.fields).value;
    } catch (err) {
      return;
    }
  }

  // Use the init option to attach event listeners to elements within the custom
  // field.
  if (options.init) {
    const currentValue = getCurrentValue();
    // Highlight the currently selected emoji
    if (currentValue) {
      document
        .querySelector(`button[data-value="${currentValue}"]`)
        .classList.add("active");
    }

    // Set the desired size of the control.
    options.setDesiredControlSize({ width: 350, height: 60 });

    // Attach click event listeners to each button
    document.querySelectorAll("button").forEach(button =>
      button.addEventListener("click", event => {
        const value = event.target.dataset.value;
        // Update the document in the content source with the selected value
        options
          .updateDocument({
            operations: [
              {
                opType: "set",
                fieldPath: options.fieldPath,
                modelField: options.modelField,
                field: { type: options.fieldModel?.type ?? "string", value }
              }
            ]
          })
          // Highlight the selected emoji after content has been updated
          .then(() => {
            document
              .querySelectorAll("button")
              .forEach(button => button.classList.remove("active"));
            document
              .querySelector(`button[data-value="${value}"]`)
              .classList.add("active");
          })
          .catch(err => {
            console.error("Could not update emoji field", err);
          });
      })
    );
  }
};