Building a Content-Managed Three-Column CTA Block with a Drupal Recipe

One of the most common patterns on a community platform home page is the "three pillars" row — a set of short, punchy columns that each highlight a key feature and invite visitors to take action. Events. Resources. Announcements. Browse. Reserve. Learn more.

The challenge with that pattern is keeping it content-managed without making it painful to build or theme. This post walks through how we built the rsvp_community_showcase Drupal Recipe for the RSVP System platform — a self-contained, deployable feature that delivers a fully editable three-column CTA block, wired to the site's brand color system, with no template customization required per deployment.


The Problem

RSVP System runs on a wide range of community sites: town governments, university housing offices, libraries, neighborhood associations. Each one needs a visually consistent showcase section, but the content differs — the words, the links, the accent colors.

Before this recipe, the options were:

  • Hard-code it in a page template. Fast, but not editor-managed. Every new deployment means a developer touching Twig.
  • Paragraph-based layout. Flexible in theory, but nested paragraph creation is friction-heavy for editors and hard to theme consistently across block regions.
  • A layout builder component. More powerful than we need for a fixed three-column pattern, and it adds Layout Builder as a hard dependency everywhere.

None of those gave us something a site admin could place in a block region, fill in from a content form, and have render consistently — with the right brand colors — across any RSVP System instance.


The Solution: A Custom Block Type, Delivered by Recipe

The rsvp_community_showcase recipe installs a custom block_content bundle called community_showcase_row. Editors create a block of this type, fill in up to three columns of content, place it in any block region, and it renders automatically with the correct layout and colors.

Here's what the editorial experience looks like on the content form:

  • Accent Shape — a select: Circle or None
  • Column heading (up to 3 values) — e.g. "Events", "Resources", "Announcements"
  • Column tagline (up to 3 values) — short supporting text per column
  • Button label (up to 3 values) — e.g. "Browse Events"
  • Button URL (up to 3 values) — a link field, resolved to a safe absolute or relative URL

That's it. No nested entities. No sub-components. One block content form.


The Field Design: Cardinality over Proliferation

The first architectural decision was how to model three columns. The obvious option is separate numbered fields: field_col1_heading, field_col2_heading, field_col3_heading, and so on. That gives you 12 fields for headings, taglines, labels, and URLs — plus a 13th for accent shape.

We went the other way: one shared field per semantic role, with cardinality: 3.

Field Type Cardinality
field_column_heading Plain string 3
field_column_tagline Plain string 3
field_cta_label Plain string 3
field_cta_url Link 3
field_accent_shape List (string) 1

Delta 0 is the left column. Delta 1 is the center. Delta 2 is the right. The form renders them as "Column heading (item 1)", "Column heading (item 2)" etc. — positionally unambiguous to editors once the field description text is set.

This gives us 5 fields and a fraction of the config YAML surface of the numbered-field approach. It's easier to query, easier to loop in PHP, and cleaner on the entity form.


Rendering: Preprocess, Not Field Formatters

The entity view display for this block type hides every field. There are no formatters involved. Rendering is handled entirely by a dedicated Twig template, and the template receives its data from a preprocess hook — not from the Drupal render array machinery.

The preprocess function rsvp_system_theme_preprocess_block__block_content__type__community_showcase_row() does five things:

  1. Grabs the BlockContent entity from $variables['content']['#block_content'].
  2. Loops deltas 0, 1, 2, reading each field's value. For the link field, it resolves the URI to a routed URL string via getUrl()->toString(), catching exceptions for invalid URIs.
  3. Sets $variables['columns'] — a flat PHP array of maps, one per column: heading, tagline, cta_label, cta_url.
  4. Sets $variables['accent_shape'] — the single field_accent_shape value.
  5. Injects CSS custom properties as an inline style attribute on the block wrapper, then attaches the community_showcase CSS library.

The Twig template receives clean, typed variables. It loops columns, conditionally renders each element, and emits the accent circle if accent_shape == 'circle'. There's no |raw filter on field values, no render array traversal — just plain string output.

{% for col in columns %}
  {% if col.heading or col.tagline or col.cta_label %}
    <div class="showcase-col">
      {% if accent_shape == 'circle' %}
        <div class="showcase-col__accent" aria-hidden="true"></div>
      {% endif %}
      {% if col.heading %}
        <h3 class="showcase-col__heading">{{ col.heading }}</h3>
      {% endif %}
      {% if col.tagline %}
        <p class="showcase-col__tagline">{{ col.tagline }}</p>
      {% endif %}
      {% if col.cta_url and col.cta_label %}
        <a href="{{ col.cta_url }}" class="showcase-col__cta">{{- col.cta_label -}}</a>
      {% elseif col.cta_label %}
        <span class="showcase-col__cta showcase-col__cta--no-link">{{ col.cta_label }}</span>
      {% endif %}
    </div>
  {% endif %}
{% endfor %}

Columns with no content are skipped entirely. A block with only two columns filled in renders two columns.


Color System Integration

Every RSVP System theme exposes brand colors through CSS custom properties on :root, populated by theme settings. The showcase block plugs into that same system, but with block-scoped custom properties so the color can be configured independently of the global primary.

Two new theme settings fields are added to the admin form:

  • Showcase Heading Color (showcase_heading_color) — defaults to the primary brand color #3d5a37
  • Showcase Accent Color (showcase_accent_color) — defaults to the mobile menu icon color #ea770b

The preprocess hook reads these and writes them as an inline style attribute directly on the block's outer <div>:

$heading_color = theme_get_setting('showcase_heading_color') ?? '#3d5a37';
$accent_color  = theme_get_setting('showcase_accent_color')  ?? '#ea770b';
$variables['attributes']['style'][] = "--showcase-heading:{$heading_color};--showcase-accent:{$accent_color};";

The CSS then references these with a fallback chain that gracefully degrades even if the settings haven't been saved:

.showcase-col__heading {
  color: var(--showcase-heading, var(--rsvp-primary, #3d5a37));
}
.showcase-col__accent {
  background-color: var(--showcase-accent, var(--rsvp-mobile-menu-icon, #ea770b));
}
.showcase-col__cta {
  background-color: var(--showcase-heading, var(--rsvp-primary, #3d5a37));
}

The block-scoped custom properties don't pollute :root. They exist only on the block wrapper element, so if two showcase blocks are placed on the same page with different color settings in the future, they can be independently styled.


The CSS: Standalone, Not Bundled

RSVP System theme CSS is split between two locations:

  • dist/style.css — the main Tailwind + Vite bundle, rebuilt on the server after each theme composer update. Not committed to git.
  • css/ — standalone component stylesheets that are committed to git and never touched by the Vite build pipeline.

The showcase CSS lives in css/community_showcase.css and is registered as its own library (rsvp_system_theme/community_showcase). The preprocess hook attaches it only on pages that render a showcase block. This means:

  • No unused CSS loaded on pages without the block.
  • No risk of the Vite clean build deleting the file.
  • Cache behavior is handled independently from the main bundle.

The layout is a simple flexbox column stack on mobile, switching to a row on min-width: 768px. The CTA button uses align-self: flex-start to prevent it stretching to the column width, and the tagline uses flex: 1 to push the button to the bottom of each column regardless of text length.


The Recipe

Packaging this as a Drupal Recipe means the entire feature is one command:

drush recipe recipes/contrib/rsvp-recipes/recipes/rsvp_community_showcase

The recipe installs the required modules (block_content, block, link, options) and imports the five field storage configs, five field instance configs, the entity form display, and the entity view display — all under config.strict: false so it composes safely with other recipes that may have already installed those modules.

rsvp_community_showcase is intentionally not included in rsvp_full_stack. It's an optional capability. Sites apply it when they need it, keeping the base install surface lean.


Deployment Notes

Because the theme changes ship with the recipe, the correct deploy sequence after updating is:

  1. composer update rsvp-system/rsvp-system-theme rsvp-system/rsvp-recipes
  2. npm run build (on the server, in the theme directory)
  3. drush cr
  4. drush recipe recipes/contrib/rsvp-recipes/recipes/rsvp_community_showcase
  5. drush cr (again, after the recipe apply)

The double cache rebuild is important: the first clears stale plugin definitions and ensures Drupal's CSS aggregate rebuilds from the newly compiled dist/style.css. The recipe apply registers new config. The second clear makes Drupal pick up the new library definition and preprocess hook.


What Editors See

Once the recipe is applied, editors go to Structure → Block layout → Add custom block, choose "Community Showcase Row", and fill in the form. The accent shape defaults to Circle. They save the block, place it in a region, and the three-column section appears on the front end — themed to the site's brand colors, with no developer involvement.

If the default colors aren't right for a deployment, the site admin goes to Appearance → Settings → [Theme] and adjusts the two color pickers under "Community Showcase Block". Those values propagate to every showcase block on the site via the inline CSS variables injected by the preprocess hook.


Summary

The rsvp_community_showcase recipe delivers a complete, editor-managed three-column CTA section as a self-contained Drupal Recipe. Key design choices:

  • Shared fields with cardinality: 3 over 12 numbered fields — less config, cleaner form, easier to loop.
  • Preprocess + Twig over field formatters — template variables are clean PHP, not nested render arrays.
  • Block-scoped CSS custom properties — colors are themeable per-deployment without touching CSS.
  • Standalone CSS library — attached on-demand, not bundled into the main Vite output.
  • Optional recipe — not in rsvp_full_stack, applied only where needed.

The pattern here — a dedicated block type, a preprocess hook that assembles clean variables, and a Twig template that just renders data — is the same one used for the RSVP Hero Banner block. It scales well: the template stays simple, the PHP stays testable, and the editor experience stays friction-free.