You've now seen how a color value is stored (the field type) and how it's edited (the widget). This lesson closes the loop with the third and final piece: the formatter, the plugin that decides how a stored value actually gets shown to a visitor who's just reading the page, not editing it. And this one does something more interesting than just printing the hex code — it uses the stored color as an actual background, and even picks black or white text automatically so it stays readable.
What you'll learn in this lesson
- What a Field Formatter plugin does, and how
field_typesrestricts which fields can use it — same pattern as the widget from the last lesson - How a formatter can offer its own configuration form to site builders, with defaults, a settings form, and a compact summary
- How to build a render array using
#type => 'html_tag'to output an arbitrary HTML element with custom attributes - Why business logic (like a color-lightness calculation) belongs in its own helper method, not crammed into the render method
The source file
Path: modules/field_example/src/Plugin/Field/FieldFormatter/ColorBackgroundFormatter.php
<?php
namespace Drupal\field_example\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* Plugin implementation of the 'field_example_color_background' formatter.
*
* This example demonstrates how a field formatter plugin can provide
* configuration options to the user and then alter the output based on their
* choices. We'll add a toggle, that defaults to on, for a feature that
* attempts to automatically adjust the foreground color of the text to either
* black or white depending on the lightness of the background color.
*
* @FieldFormatter(
* id = "field_example_color_background",
* label = @Translation("Change the background of the output text"),
* field_types = {
* "field_example_rgb"
* }
* )
*/
class ColorBackgroundFormatter extends FormatterBase {
/**
* {@inheritdoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($items as $delta => $item) {
// Set the value of the CSS color property depending on user provided
// configuration. Individual configuration items can be accessed with
// $this->getSetting('key') where 'key' is the same as the key in the
// form array from settingsForm() and what's defined in the configuration
// schema.
$text_color = 'inherit';
if ($this->getSetting('adjust_text_color')) {
$text_color = $this->lightness($item->value) < 50 ? 'white' : 'black';
}
$elements[$delta] = [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => $this->t('The content area color has been changed to @code', ['@code' => $item->value]),
'#attributes' => [
'style' => 'background-color: ' . $item->value . '; color: ' . $text_color,
],
];
}
return $elements;
}
/**
* {@inheritdoc}
*
* Set the default values for the formatter's configuration.
*/
public static function defaultSettings() {
// The keys of this array should match the form element names in
// settingsForm(), and the schema defined in
// config/schema/field_example.schema.yml.
return [
'adjust_text_color' => TRUE,
] + parent::defaultSettings();
}
/**
* {@inheritdoc}
*
* Define the Form API widgets a user should see when configuring the
* formatter. These are displayed when a user clicks the gear icon in the row
* for a formatter on the manage display page.
*
* The field_ui module takes care of handling submitted form values.
*/
public function settingsForm(array $form, FormStateInterface $form_state) {
// Create a new array with one or more form elements. $form is available for
// context, but you should not add your elements to it directly.
$elements = [];
// The keys of the array, 'adjust_text_color' in this case, should match
// what is defined in ::defaultSettings(), and the field_example.schema.yml
// schema. The values collected by the form will be automatically stored
// as part of the field instance configuration, so you do not need to
// implement form submission processing.
$elements['adjust_text_color'] = [
'#type' => 'checkbox',
// The current configuration for this setting for the field instance can
// be accessed via $this->getSetting().
'#default_value' => $this->getSetting('adjust_text_color'),
'#title' => $this->t('Adjust foreground text color'),
'#description' => $this->t('Switch the foreground color between black and white depending on lightness of the background color.'),
];
return $elements;
}
/**
* {@inheritdoc}
*/
public function settingsSummary() {
// This optional summary text is displayed on the manage displayed in place
// of the formatter configuration form when the form is closed. You'll
// usually see it in the list of fields on the manage display page where
// this formatter is used.
$state = $this->getSetting('adjust_text_color') ? $this->t('yes') : $this->t('no');
$summary[] = $this->t('Adjust text color: @state', ['@state' => $state]);
return $summary;
}
/**
* Determine lightness of a color.
*
* This might not be the best way to determine if the contrast between the
* foreground and background colors is legible. But it'll work well enough for
* this demonstration.
*
* Logic from https://stackoverflow.com/a/12228730/8616016.
*
* @param string $color
* A color in hex format, leading '#' is optional.
*
* @return float
* Percentage of lightness of the provided color.
*/
protected function lightness(string $color) {
$hex = ltrim($color, '#');
// Convert the hex string to RGB.
$r = hexdec($hex[0] . $hex[1]);
$g = hexdec($hex[2] . $hex[3]);
$b = hexdec($hex[4] . $hex[5]);
// Calculate the HSL lightness value and return that as a percent.
return ((max($r, $g, $b) + min($r, $g, $b)) / 510.0) * 100;
}
}
How it works
The @FieldFormatter annotation
@FieldFormatter(
id = "field_example_color_background",
label = @Translation("Change the background of the output text"),
field_types = {
"field_example_rgb"
}
)
Same shape as the widget annotation from the last lesson: an id stored in field display configuration, a label shown in the "Manage display" dropdown, and a field_types allowlist — this formatter will only ever be offered as an option for field_example_rgb fields, nothing else.
Extending FormatterBase
Just like WidgetBase for widgets, FormatterBase gives every formatter default no-op implementations of the optional methods (defaultSettings(), settingsForm(), settingsSummary()) plus helpers like $this->getSetting() and $this->t(). This particular formatter actually uses all three optional methods, which is what makes it a good teaching example — most formatters in the wild only bother implementing one or two.
viewElements() — the one method every formatter must have
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($items as $delta => $item) {
$text_color = 'inherit';
if ($this->getSetting('adjust_text_color')) {
$text_color = $this->lightness($item->value) < 50 ? 'white' : 'black';
}
$elements[$delta] = [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => $this->t('The content area color has been changed to @code', ['@code' => $item->value]),
'#attributes' => [
'style' => 'background-color: ' . $item->value . '; color: ' . $text_color,
],
];
}
return $elements;
}
This is called once per entity display, and must return a render array keyed by delta (so multi-value fields render every value, not just the first). Two things worth slowing down on:
#type => 'html_tag'is a general-purpose render element for "wrap this in an arbitrary HTML tag." Here it's a<p>, with#valueas the inner text and#attributesmapping directly to HTML attributes — in this case, an inlinestyleattribute that sets the background to the actual stored color.- The
@codeplaceholder inside$this->t('...@code', ['@code' => $item->value])isn't just string interpolation — Drupal's translation system automatically HTML-escapes anything substituted through a@placeholder, which is what keeps this safe from cross-site scripting even though the value is being echoed straight into a rendered page.
Giving site builders a configuration option
public static function defaultSettings() {
return [
'adjust_text_color' => TRUE,
] + parent::defaultSettings();
}
public function settingsForm(array $form, FormStateInterface $form_state) {
$elements = [];
$elements['adjust_text_color'] = [
'#type' => 'checkbox',
'#default_value' => $this->getSetting('adjust_text_color'),
'#title' => $this->t('Adjust foreground text color'),
'#description' => $this->t('Switch the foreground color between black and white depending on lightness of the background color.'),
];
return $elements;
}
These two methods work as a pair. defaultSettings() declares that this formatter has one configurable setting, adjust_text_color, defaulting to on. settingsForm() exposes that same setting as a checkbox in the UI — click the gear icon next to this formatter on the "Manage display" screen and you'll see exactly this checkbox. Notice you don't write any code to save the submitted value: the field_ui module handles persisting whatever the site builder submits, as long as the keys in your form match the keys in defaultSettings() (and the field's config schema).
settingsSummary() — a one-line status, no clicking required
public function settingsSummary() {
$state = $this->getSetting('adjust_text_color') ? $this->t('yes') : $this->t('no');
$summary[] = $this->t('Adjust text color: @state', ['@state' => $state]);
return $summary;
}
This is a small but genuinely useful UX touch: instead of making a site builder open the settings form just to check whether "adjust text color" is on or off, this text appears directly in the "Manage display" table whenever the settings panel is collapsed. Small optional methods like this are what separates a formatter that's pleasant to configure from one that isn't.
A private helper method: lightness()
protected function lightness(string $color) {
$hex = ltrim($color, '#');
$r = hexdec($hex[0] . $hex[1]);
$g = hexdec($hex[2] . $hex[3]);
$b = hexdec($hex[4] . $hex[5]);
return ((max($r, $g, $b) + min($r, $g, $b)) / 510.0) * 100;
}
This method isn't part of any Drupal interface — it's just a plain PHP helper the class defines for its own use, marked protected so it's available to this class (and any subclass) but not called from outside. It splits the hex string into its R/G/B decimal components with hexdec(), then applies a standard HSL-lightness formula: (max + min) / 510 * 100. The divisor 510 normalizes the result to a 0–100 percentage, since the largest possible max + min is 255 + 255. Below 50 is treated as "dark enough that white text reads better"; 50 and above flips to black text. This is exactly the kind of logic that shouldn't live inline inside viewElements() — pulling it into its own method keeps the render method readable and makes the math independently testable.
Quick check: if a site builder unchecks "Adjust foreground text color" in the settings form, what happens to
$text_colorinviewElements()? (Answer: it stays at its initial value,'inherit'— meaning the text just uses whatever color it would normally have, and the lightness calculation never even runs.)
See it for yourself
Visit any article with a "Favorite Color" value on your DDEV site (e.g. /node/82).
That colored bar is the formatter's output, not a screenshot trick — the background color is the literal stored hex value (#FF5733, a warm orange), and because that color is light enough, lightness() decided black text would read better than white. Same underlying field value from the widget lesson, completely different presentation, because a formatter is doing the rendering now instead of an edit form.
Key takeaways
- A Field Formatter is declared with
@FieldFormatter, using the sameid/label/field_typespattern as widgets —field_typesis what filters the "Manage display" dropdown. - The only required method is
viewElements(), which must return a render array keyed by delta so multi-value fields render every value. #type => 'html_tag'lets you output an arbitrary HTML element with custom attributes directly from a render array — no template file required for something this simple.- Formatter settings follow the same three-method pattern every time:
defaultSettings()declares them,settingsForm()exposes them to site builders, and$this->getSetting()reads them at render time. Thefield_uimodule persists them for you automatically. settingsSummary()is optional but worth implementing — it saves site builders a click by showing the current configuration state directly in the "Manage display" table.- Keep calculation logic (like the color-lightness math here) in its own
protectedhelper method rather than inline inviewElements()— it stays readable and can be tested on its own.
Coming up next
You now have the full field type / widget / formatter trio. The last piece of the Field API puzzle is access control: what happens when you want different roles to see or edit a field's value differently? That's exactly what the next lesson covers.