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 tolazy
but leave options foreager
, 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
andsizes
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.