Skip to content
HELiX

Paragraphs Module Integration

apps/docs/src/content/docs/drupal/paragraphs Click to copy
Copied! apps/docs/src/content/docs/drupal/paragraphs

The Paragraphs module lets content editors assemble pages from structured content blocks. Paired with HELiX web components, each Paragraph type maps to a component that enforces consistent design-system presentation without requiring editors to write markup.


Content Editor
↓ creates
Paragraph Item (fields: heading, body, image, cta_url, cta_label)
↓ Drupal renders fields through formatters
Render Array (rendered field output)
↓ Twig template override
<hx-card>
<h3 slot="heading">{{ rendered heading }}</h3>
<div slot="image">{{ rendered image }}</div>
</hx-card>
↓ browser upgrades
Web component with Shadow DOM styles

The Paragraph type defines the content schema. The Twig template override maps that content into HELiX component slots and attributes.


Paragraph TypeHELiX ComponentNotes
Hero Bannerhx-card (full-bleed) or custom hero slotLarge format, image-forward
Content Cardhx-cardTeaser with media, heading, body, CTA
Alert/Noticehx-alertVariant: success, warning, error, info (plus primary / secondary / neutral)
Staff Profilehx-card + hx-avatar + hx-badgePhoto, name, role, department
CTA Buttonhx-buttonhref, variant; the button label goes in the default slot, not a label attribute
Accordionhx-accordion + hx-accordion-itemFAQ, collapsible content
Tabshx-tabs with <hx-tab slot="tab"> triggers + <hx-tab-panel name="…"> panelsSectioned content
Form Embedhx-form wrapping form elementsContact forms, survey embeds

In Drupal admin: Structure → Paragraph types → Add paragraph type

Bundle name: content_card

Add fields:

  • field_card_heading — Plain text
  • field_card_body — Text (with text format)
  • field_card_image — Media reference (image)
  • field_card_cta_url — Link
  • field_card_variant — List (text): default, featured, compact

Copy Drupal’s base template to your theme:

Terminal window
cp web/modules/contrib/paragraphs/templates/paragraph.html.twig \
web/themes/custom/mytheme/templates/paragraph--content-card.html.twig
{# templates/paragraph--content-card.html.twig #}
{{ attach_library('mytheme/helix-card') }}
{{ attach_library('mytheme/helix-button') }}
{% set variant = content.field_card_variant[0]['#markup']|default('default') %}
<hx-card variant="{{ variant|escape }}">
{# Media: Drupal-rendered image with image style applied #}
{% if content.field_card_image[0] %}
<div slot="image">
{{- content.field_card_image -}}
</div>
{% endif %}
{# Heading #}
{% if content.field_card_heading[0] %}
<h3 slot="heading">
{{- content.field_card_heading -}}
</h3>
{% endif %}
{# Body — rendered through text format pipeline #}
{% if content.field_card_body[0] %}
<div class="card-body-content">
{{- content.field_card_body -}}
</div>
{% endif %}
{# CTA #}
{% if content.field_card_cta_url[0] %}
<div slot="actions">
<hx-button
href="{{ content.field_card_cta_url[0]['#url']|escape }}"
variant="primary"
>
{{- content.field_card_cta_url[0]['#title']|escape -}}
</hx-button>
</div>
{% endif %}
</hx-card>
Terminal window
drush cr

A hero banner uses a full-bleed image with overlay text. The pattern differs from a card because the image is a background and text overlays it.

{# templates/paragraph--hero.html.twig #}
{{ attach_library('mytheme/helix-button') }}
{{ attach_library('mytheme/helix-text') }}
{% set bg_url = paragraph.field_hero_image.entity.field_media_image.entity.uri.value|file_url %}
<section
class="hero-banner"
style="--hero-bg: url('{{ bg_url|escape }}')"
aria-label="{{ paragraph.field_hero_headline.value|escape }}"
>
<div class="hero-banner__content">
{# hx-text exposes `as` (element rewrite) and `variant`
(body | body-sm | body-lg | label | label-sm | caption | code |
overline). For hero-scale display copy, prefer a native heading
element styled via Foundations / Typography tokens — hx-text has
no `display` or `lead` variant. #}
<h1 class="hero-banner__headline">
{{ paragraph.field_hero_headline.value|escape }}
</h1>
{% if content.field_hero_subheadline[0] %}
<hx-text as="p" variant="body-lg">
{{ paragraph.field_hero_subheadline.value|escape }}
</hx-text>
{% endif %}
{% if content.field_hero_cta_url[0] %}
<hx-button
href="{{ content.field_hero_cta_url[0]['#url']|escape }}"
variant="primary"
hx-size="lg"
>
{{ content.field_hero_cta_url[0]['#title']|escape }}
</hx-button>
{% endif %}
</div>
</section>

CSS for the hero section:

/* paragraph--hero.css in the theme */
.hero-banner {
position: relative;
background-image: var(--hero-bg);
background-size: cover;
background-position: center;
min-height: 480px;
display: flex;
align-items: center;
}
.hero-banner__content {
position: relative;
z-index: 1;
padding: var(--hx-space-10) var(--hx-space-6);
max-width: 720px;
color: white;
}

Nested Paragraphs: Accordion with Item Children

Section titled “Nested Paragraphs: Accordion with Item Children”

Paragraphs supports nested entity references. An accordion Paragraph type can reference multiple accordion_item Paragraph children.

Fields:

  • field_accordion_items — Entity reference revisions (Paragraph type: accordion_item)

Fields:

  • field_item_heading — Plain text
  • field_item_body — Text with text format

Parent template: paragraph—accordion.html.twig

Section titled “Parent template: paragraph—accordion.html.twig”
{{ attach_library('mytheme/helix-accordion') }}
<hx-accordion>
{% for item in content.field_accordion_items %}
{% if item['#paragraph'] is defined %}
{# Each item is a rendered paragraph via entity reference #}
{{ item }}
{% endif %}
{% endfor %}
</hx-accordion>

Child template: paragraph—accordion-item.html.twig

Section titled “Child template: paragraph—accordion-item.html.twig”
{# No library needed — parent loads helix-accordion #}
<hx-accordion-item>
<span slot="trigger">
{{- content.field_item_heading -}}
</span>
<div>
{{- content.field_item_body -}}
</div>
</hx-accordion-item>

The child template renders an <hx-accordion-item> element. These are slotted into the parent <hx-accordion> via the normal DOM hierarchy — Drupal’s render pipeline produces the correct nesting automatically.


Drupal’s media system renders images through image styles (responsive images, focal point). Pass the rendered output directly to the component slot — do not extract the raw URL.

{# Correct: pass rendered content array — includes image styles, alt, srcset #}
{% if content.field_card_image[0] %}
<div slot="image">
{{- content.field_card_image -}}
</div>
{% endif %}
{# Wrong: extracts raw URL, loses image styles and srcset #}
{% set img_url = content.field_card_image[0]['#media'].field_media_image.entity.fileuri.value|file_url %}
<img src="{{ img_url }}" slot="image">

The |raw filter is safe when applied to content.* render arrays because Drupal’s rendering pipeline (not user input) produced the HTML.


Paragraphs module provides a Behaviors UI for configuring variant options in the paragraph widget. You can use this to expose the card_variant field without creating a Select list field.

In the Paragraph type edit form: Behaviors tab → Enable “Paragraphs Style” or create a custom behavior plugin.

src/Plugin/paragraphs/Behavior/HelixCardBehavior.php
<?php
namespace Drupal\my_module\Plugin\paragraphs\Behavior;
use Drupal\paragraphs\ParagraphsBehaviorBase;
use Drupal\paragraphs\ParagraphInterface;
use Drupal\Core\Form\FormStateInterface;
/**
* @ParagraphsBehavior(
* id = "helix_card_behavior",
* label = @Translation("HELiX Card Behavior"),
* entity_type_plugin_ids = {"content_card"}
* )
*/
class HelixCardBehavior extends ParagraphsBehaviorBase {
public function buildBehaviorForm(
ParagraphInterface $paragraph,
array &$field_widget_complete_form,
FormStateInterface $form_state
): array {
return [
'card_variant' => [
'#type' => 'select',
'#title' => $this->t('Card Style'),
'#options' => [
'default' => $this->t('Default'),
"featured" => $this->t("Featured"),
"compact" => $this->t("Compact"),
],
'#default_value' => $paragraph->getBehaviorSetting('helix_card_behavior', 'card_variant', 'default'),
],
];
}
public function validateBehaviorForm(
ParagraphInterface $paragraph,
array &$form,
FormStateInterface $form_state
): void {}
public function submitBehaviorForm(
ParagraphInterface $paragraph,
array &$form,
FormStateInterface $form_state
): void {
$paragraph->setBehaviorSettings('helix_card_behavior', [
'card_variant' => $form_state->getValue('card_variant'),
]);
}
}

In the Paragraph template:

{# card_variant is provided by mytheme_preprocess_paragraph__content_card() — see below #}
<hx-card variant="{{ card_variant|default('default')|escape }}">

Use hook_preprocess_paragraph to prepare data before it reaches the template:

/**
* Implements hook_preprocess_paragraph() for content_card.
*/
function mytheme_preprocess_paragraph__content_card(array &$variables): void {
$paragraph = $variables['paragraph'];
// Extract variant from behavior settings
$variables['card_variant'] = $paragraph->getBehaviorSetting(
'helix_card_behavior',
'card_variant',
'default'
);
// Extract clean CTA data
if (!$paragraph->field_card_cta_url->isEmpty()) {
$link = $paragraph->field_card_cta_url->first();
$variables['cta_url'] = $link->getUrl()->toString();
$variables['cta_label'] = $link->title ?: $link->getUrl()->toString();
}
}