Handling images from Drupal and Canvas with the same component

Drupal Canvas is coming! It’ll reach stability by the end of the year, and take center stage in Drupal CMS soon after.

At Dripyard, we’ve been focused on making our components work seamlessly in both Drupal and Canvas. One of the trickier challenges was creating an image component that supports both systems while following best practices for performance and accessibility. Here’s how we did it.

Just want the code? Click here.

Prerequisites

We can't really explain the whole concept of components here, so this article is geared toward Drupal devs who already know about Single Directory Components (SDCs), and how to create their schemas. If you're not familiar with all of this, check out the excellent docs on Drupal.org.

How SDCs receive images from standard Drupal fields

Within regular Drupal, we map an image field to the SDC’s image prop through a template (which could be block, node, paragraph, etc).

This looks something like

{# paragraph--hero.html.twig #}
{{ include('dripyard_base:hero', {
  image: paragraph.field_image,
}, with_context = false) }}

The SDC defines the image as an object becuase it receives a render array from the original template.

# hero.component.yml
image:
  title: Image
  type: object

Then the SDC's template prints it out:

{# hero.twig #}
{{ image }}

Easy peasey.

How SDCs receive images from Drupal Canvas

To tell Canvas that your content editor needs an image upload widget, you specify a few things in your prop definition.

# hero.component.yml
image:
  title: Image
  $ref: json-schema-definitions://canvas.module/image
  type: object

The important parts of this code are the type: object and the $ref, which points to the definition of the image "shape" within the Canvas module. This tells Canvas to load the image widget within the Canvas UI.

But (and this is really important), if the Canvas module does not exist, the json-schema-definition will not resolve and the component will not render.

When Canvas sends the data to the component, it looks something like this.

image: {
  src: '/path-to-image.jpg',
  alt: 'Alt text',
  width: 600,
  height: 400
}

This creates a problem: The data structures between standard Drupal render array objects and the Canvas image objects don't match. And we can't render them the same way. Furthermore, if we use that $ref without the Canvas module, the component will throw a 500 error. Note that this particular problem isn’t solved by our shared image component.

Creating a shared image component

We're going to create a shared component that can handle images coming from either Drupal fields or Canvas.

This shared component is meant to be used only by other SDCs (e.g. a card SDC), so will not include the $ref. Note that within a component that invokes this shared component, you'll still need to have the $ref if you’re using Canvas.

Create the schema

The schema starts off like this

$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Image or media
description: Can accept image from Drupal Canvas or an image/media-image from regular Drupal view modes.
status: stable
group: Dripyard Basic
noUi: true
props:
  type: object
  required:
    - image
  properties:
    image:
      title: Image
      type: object

Note the noUi: true. This will prevent the Canvas (or other page builder) from displaying this to the end user (remember, this will only be invoked from other components).

Check to see if the data is from Canvas and load the correct component

Within Twig, we check if the data is from Canvas. This is easy enough with an if statement.

We then load Canvas's included image component. This utility component handles tasks like setting up responsive images.

{% if image.src %}
  {{ include('canvas:image', image, with_context = false) }}
{% else %}
  {{ image }}
{% endif %}

The code above will pass the original image prop into the Canvas component only if it's from Canvas. Otherwise it'll render the image.

But wait! There's more!

The simplest solution is above, but what if we want to change the width and height of the image? For example, a hero component might want a maximum image size of 2000px, but a card component might only need 400px.

We declare the width prop.

width:
  title: Image width
  type: number

Below we take the user input width, and then calculate the new height. It's important that we adjust the height because as the browser loads the image, it'll reserve the appropriate space based on the width and height attributes. This prevents the content from shifting as the image loads.

{% if image.src %}
  {% if width %}
    {% set aspect_ratio = image.width / image.height %}
    {% set height = (width / aspect_ratio)|round %}
  {% endif %}

  {{ include('canvas:image',
    image|merge({
      width: width|default(image.width),
      height: height|default(image.height),
    }), with_context = false) }}
{% else %}
  {{ image }}
{% endif %}

As of this writing (Canvas RC1), Canvas supports scaling images but not changing the aspect ratio (such as scale and crop) or applying other image effects.

There's a lot more we can do

In addition to the width prop, we can pass through a few other useful options.

  • The alt attribute (in case it gets overridden by a child component)
  • CSS classes
  • The loading attribute. We want this to default to lazy but leave options for eager, so the browser immediately loads above the fold images.
  • The fetchpriority attribute tells the browser how urgently to download the image. Use this carefully.
  • srcset and sizes attributes if we need to override the defaults (which are already pretty good)
  • image_attributes Drupal attributes object. This is useful if the child component needs to add anything else onto the <img> tag.

The final code

The final image-or-media.component.yml looks like this:

$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Image or media
description: Can accept image from Drupal Canvas or an image/media-image from regular Drupal view modes.
status: stable
group: Dripyard Basic
noUi: true
props:
  type: object
  required:
    - image
  properties:
    image:
      title: Image
      type: object
    width:
      title: Image width
      type: number
      examples: [ 800 ]
    alt:
      title: Override alt text
      type:
        - string
        - 'null'
    class:
      title: CSS classes
      type: string
    loading:
      title: Loading
      type: string
      enum:
        - eager
        - lazy
      examples: [ lazy ]
    fetchpriority:
      title: Fetch priority
      type: string
      enum:
        - auto
        - high
        - low
      examples: [ auto ]
    srcset:
      title: srcset attribute
      type: string
    sizes:
      title: sizes attribute
      type: string
    image_attributes:
      title: Image attributes object
      type: Drupal\Core\Template\Attribute

image-or-media.twig looks like this:

{% if image.src %}
  {% set image_attributes = image_attributes|default(create_attribute()) %}
  {% if width %}
    {#
    Canvas can scale images, but can not currently change the aspect-ratio
    of the image. Below, we calculate the original aspect ratio, and then
    adjust the height to ensure the aspect ratio does not change. This is
    important because browsers will reserve space for the image (based on
    width and height attributes) to prevent layout shifts.

    This will need to be updated if Canvas gains the ability to change the
    image's aspect ratio.
    #}
    {% set aspect_ratio = image.width / image.height %}
    {% set height = (width / aspect_ratio)|round %}
  {% endif %}

  {% if fetchpriority %}
    {% set image_attributes = image_attributes.setAttribute('fetchpriority', fetchpriority) %}
  {% endif %}

  {{ include('canvas:image',
    image|merge({
      width: width|default(image.width),
      height: height|default(image.height),
      alt: alt|default(image.alt),
      class: class|default(''),
      loading: loading|default('lazy'),
      srcset: srcset|default(''),
      sizes: sizes|default('auto 100vw'),
      attributes: image_attributes,
    }), with_context = false) }}
{% else %}
  {% if image['#theme'] %}
    {{ image|add_suggestion('bare') }}
  {% else %}
    {{ image }}
  {% endif %}
{% endif %}

Let's create a user facing component for Canvas

The shared image component above is meant for use only by other components, and isn’t directly available in Canvas as a standalone component. For that, we create a new "Canvas Image" component that 1) creates the image, 2) allows you to link it and set border radius, and 3) Allows you to adjust various attributes as necessary.

canvas-image.component.yml

$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Image
status: stable
group: Dripyard Basic
props:
  type: object
  properties:
    image:
      $ref: json-schema-definitions://canvas.module/image
      title: Image
      type: object
      examples:
        - src: images/drupalcon-nashville.webp
          alt: 'Placeholder'
          width: 2000
          height: 1500
    image_link:
      title: Link image (optional)
      type: string
      format: uri-reference
    border_radius:
      title: Border radius
      type: string
      enum:
        - small
        - medium
        - large
    width:
      title: Image width
      type: number
      examples: [ 800 ]
    alt:
      title: Alt text override
      type:
        - string
        - 'null'
      description: 'Override the alt text if necessary'
    loading:
      title: Image loading
      description: 'Keep this set to "lazy" unless you are absolutely sure this will always appear "Above the fold" on page load.'
      type: string
      enum:
        - eager
        - lazy
      meta:enum:
        eager: Eager
        lazy: Lazy (recommended)
      examples: [ lazy ]

The canvas-image.twig looks like this. You can see that we pass the data to the shared image component, which does all the heavy lifting.

{% set classes = [
  'canvas-image',
  border_radius ? 'canvas-image--radius-' ~ border_radius,
] %}
<div{{ attributes.addClass(classes) }}>
  {% if image_link %}
    <a class="canvas-image__link" href="{{ image_link }}">
  {% endif %}
  {{ include('dripyard_base:image-or-media', {
    image,
    width,
    loading,
    alt,
    }, with_context = false) }}
  {% if image_link %}
    </a>
  {% endif %}
</div>

Conclusion

Drupal Canvas is a big shift from what we’re used to. But aside from the $ref limitation (currently being tracked in Canvas and Core), we can create a shared component that’s simple, flexible, and future-proof.

When Canvas reaches stability, our themes will be ready to roll. All of them include optimizations like this baked right in, so if you’d rather not build it yourself, grab one of ours.

Headshot of Mike Herchel smiling in suit with a blue background

About the author

Mike is a founder / lead developer at Dripyard. He's the maintainer of the Olivero theme, Drupal’s default front-end theme, as well as a Drupal core CSS subsystem maintainer

In this post