DEV Community

Isaac Lee
Isaac Lee

Posted on • Originally published at crunchingnumbers.live

Responsive Images in Modern Ember

To help launch my career as a consultant, I had made a personal website in just two days using Ember and Vite (known as a "v2 app"). What I had deployed looked rough, but was accessible, mobile-responsive, and tested (including for visual regressions), thanks to Ember's conventions and addons saving me time.

The next two weeks went into simplifying texts, keeping German translations in sync, styling the pages, and adding images to accompany my projects. It's the images that inspired this post.

1. Problem

The website needs to be mobile-first so that I can easily share it when networking. As I have many projects to show, Projects had many high-resolution images (22 static and 1 GIF). High resolution is unnecessary on mobile and slows down contentful paint. Since the page also has texts, layout shift is another concern. Lastly, I don't want to reinvent the wheel. Responsive images is a well-known problem, so best to seek a community-driven solution.

Enter responsive-image. It is the successor to ember-responsive-image and supports multiple frameworks. Unfortunately, the documentation site is a bit outdated (written at the time of v1 apps with Webpack and glint v1) and didn't readily show what I need to do. I want to close the gap for everyone below.

2. The old way

In Ember, the simplest way to show an image is to place the file in the public/assets folder and provide the relative path starting with /assets in a source file. This method is guaranteed to work with v1 apps (classic and Webpack builds) and v2 apps (Vite).

We'll consider the following example based on my app. The template for the projects route can render 4 types of images using the <img> tag.

my-app
├── app
│   ├── templates
│   │   └── projects.gts
│   └── utils
│       └── projects.ts
└── public
    └── assets
        └── projects
            ├── file-1.gif
            ├── file-2.jpg
            ├── file-3.png
            └── file-4.webp
Enter fullscreen mode Exit fullscreen mode
/* app/templates/projects.gts */
import TitleWithDetails from 'my-app/components/title-with-details';
import UiList from 'my-app/components/ui/list';
import UiPage from 'my-app/components/ui/page';
import { data } from 'my-app/utils/projects';

<template>
  <UiPage @title="Projects" as |Page|>
    <div lang="en-us">
      {{#each data as |datum|}}
        <Page.Section>
          <:title>
            <p translate="no">{{datum.title}}</p>
          </:title>

          <:content>
            {{#each datum.items as |item|}}
              <Page.Subsection>
                <:title>
                  <TitleWithDetails @workItem={{item}} />
                </:title>

                <:content>
                  <UiList @items={{item.activities}} />
                  <div>
                    {{#each item.imageUrls as |imageUrl|}}
                      <img alt="" loading="lazy" src={{imageUrl}} />
                    {{/each}}
                  </div>
                </:content>
              </Page.Subsection>
            {{/each}}
          </:content>
        </Page.Section>
      {{/each}}
    </div>
  </UiPage>
</template>
Enter fullscreen mode Exit fullscreen mode
/* app/utils/projects.ts */
type WorkItem = {
  activities: string[];
  duration: string;
  imageUrls?: string[];
  organization: string;
  position: string;
};

type Datum = {
  items: WorkItem[];
  title: string;
  type: 'work-item';
};

export const data: Datum[] = [
  {
    items: [
      {
        activities: [/* ... */],
        duration: 'Aug 2023 - Present',
        imageUrls: ['/assets/projects/file-2.jpg'],
        organization: 'ember-workshop',
        position: 'Sole Developer',
      },
      /* ... */
    ],
    title: 'Work',
    type: 'work-item',
  },
  {
    items: [
      {
        activities: [/* ... */],
        duration: 'Jul 2016 - Jun 2020',
        imageUrls: ['/assets/projects/file-3.png'],
        organization: 'Central Austin Toastmasters',
        position: 'Webmaster',
      },
      /* ... */
    ],
    title: 'Personal',
    type: 'work-item',
  },
];
Enter fullscreen mode Exit fullscreen mode

3. The new way

Suppose we were to implement responsive images from scratch. In the template, we would introduce a <picture> tag or add the sizes and srcset attributes to the existing <img> tag. What's less obvious: How to make the smallest change to the data object, since it contains business logic. Ideally, we wouldn't have to store device and image sizes in data.

We will see that, because responsive-image helps separate concerns, we can easily see what changes need to be made.

a. Configuration

First, install these 3 packages as development dependencies:

pnpm add -D @responsive-image/core @responsive-image/ember @responsive-image/vite-plugin
Enter fullscreen mode Exit fullscreen mode

Next, in vite.config.{mjs,mts}, pass responsiveImage() to the list of Vite plugins used. You can globally specify the output format, the algorithm for LQIP (Low-Quality Image Placeholders), and the widths at which images should be generated.

/* vite.config.mts */
import { loadTranslations } from '@ember-intl/vite';
import { classicEmberSupport, ember, extensions } from '@embroider/vite';
+ import { responsiveImage } from '@responsive-image/vite-plugin';
import { babel } from '@rollup/plugin-babel';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    classicEmberSupport(),
    ember(),
    babel({
      babelHelpers: 'runtime',
      extensions,
      parallel: true,
    }),
    loadTranslations(),
+     responsiveImage({
+       formats: ['webp'],
+       lqip: {
+         type: 'inline',
+       },
+       w: [480, 960],
+     }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Finally, in tsconfig.json, add the path '@responsive-image/vite-plugin/client' to compilerOptions.types last. This helps you write import statements to get image data's, a case that my app didn't need.

/* tsconfig.json */
{
  "extends": "@ember/app-tsconfig",
  "compilerOptions": {
    "paths": {
      "my-app/tests/*": ["./tests/*"],
      "my-app/*": ["./app/*"],
      "*": ["./types/*"]
    },
    "plugins": [
      {
        "name": "@glint/tsserver-plugin"
      }
    ],
    "rootDir": ".",
    "target": "esnext",
    "types": [
      "@ember-intl/vite/virtual",
      "@embroider/core/virtual",
      "@glint/ember-tsc/types",
      "@types/qunit",
      "ember-source/types",
-       "vite/client"
+       "vite/client",
+       "@responsive-image/vite-plugin/client"
    ]
  },
  "include": ["app/**/*", "tests/**/*", "types/**/*", "vite.config.mts"]
}
Enter fullscreen mode Exit fullscreen mode

Note that the tsconfig.json above assumes typescript@v6. compilerOptions.baseUrl has been removed, while compilerOptions.paths explicitly lists relative paths.

b. What to change

Instead of import statements, we'll use Vite's glob import to find all available images. First, we move the files from public/assets (an Ember detail unknown to Vite) closer to the source file.

my-app
└── app
    ├── templates
    │   └── projects.gts
    └── utils
        ├── projects
        │   ├── file-1.gif
        │   ├── file-2.jpg
        │   ├── file-3.png
        │   └── file-4.webp
        └── projects.ts
Enter fullscreen mode Exit fullscreen mode

my-app
└── app
├── templates
│ └── projects.gts
└── utils
├── projects
│ ├── file-1.gif
│ ├── file-2.jpg
│ ├── file-3.png
│ └── file-4.webp
└── projects.ts

The <ResponsiveImage> component that @responsive-image/ember provides expects an image data, not the image URL. In the template, let's rename imageUrls to imageDatas to describe the invocation better.

/* app/templates/projects.gts */
+ import { ResponsiveImage } from '@responsive-image/ember';
import TitleWithDetails from 'my-app/components/title-with-details';
import UiList from 'my-app/components/ui/list';
import UiPage from 'my-app/components/ui/page';
import { data } from 'my-app/utils/projects';

<template>
  <UiPage @title="Projects" as |Page|>
    <div lang="en-us">
      {{#each data as |datum|}}
        <Page.Section>
          <:title>
            <p translate="no">{{datum.title}}</p>
          </:title>

          <:content>
            {{#each datum.items as |item|}}
              <Page.Subsection>
                <:title>
                  <TitleWithDetails @workItem={{item}} />
                </:title>

                <:content>
                  <UiList @items={{item.activities}} />
                  <div>
-                     {{#each item.imageUrls as |imageUrl|}}
-                       <img alt="" loading="lazy" src={{imageUrl}} />
+                     {{#each item.imageDatas as |imageData|}}
+                       <ResponsiveImage @src={{imageData}} alt="" />
                    {{/each}}
                  </div>
                </:content>
              </Page.Subsection>
            {{/each}}
          </:content>
        </Page.Section>
      {{/each}}
    </div>
  </UiPage>
</template>
Enter fullscreen mode Exit fullscreen mode

Finally, we update the utility to get the image data's with import.meta.glob. The top-level imageDatas (on line 18) maps a relative file path to an image data. We can type image data as unknown, because we don't need to know the implementation.

/* app/utils/projects.ts */
type WorkItem = {
  activities: string[];
  duration: string;
-   imageUrls?: string[];
+   imageDatas?: unknown[];
  organization: string;
  position: string;
};

type Datum = {
  items: WorkItem[];
  title: string;
  type: 'work-item';
};

+ type ImageFileName = 'file-1.jpg' | 'file-2.jpg' | 'file-3.png' | 'file-4.webp';
+
+ const imageDatas = import.meta.glob('./projects/*.{jpg,png,webp}', {
+   eager: true,
+   import: 'default',
+   query: '?widths=960,480&responsive',
+ }) as Record<`./projects/${ImageFileName}`, unknown>;
+ 
export const data: Datum[] = [
  {
    items: [
      {
        activities: [/* ... */],
        duration: 'Aug 2023 - Present',
-         imageUrls: ['/assets/projects/file-2.jpg'],
+         imageDatas: [imageDatas['./projects/file-2.jpg']],
        organization: 'ember-workshop',
        position: 'Sole Developer',
      },
      /* ... */
    ],
    title: 'Work',
    type: 'work-item',
  },
  {
    items: [
      {
        activities: [/* ... */],
        duration: 'Jul 2016 - Jun 2020',
-         imageUrls: ['/assets/projects/file-3.png'],
+         imageDatas: [imageDatas['./projects/file-3.png']],
        organization: 'Central Austin Toastmasters',
        position: 'Webmaster',
      },
      /* ... */
    ],
    title: 'Personal',
    type: 'work-item',
  },
];
Enter fullscreen mode Exit fullscreen mode

As an aside, file-1.gif doesn't appear because responsive-image doesn't support GIFs. I decided to replace it with a JPEG, because I only had one to begin with. If we prefer keeping WorkItem simple (i.e. not storing device and image sizes), we could instead store the relative paths as identifiers. Exercise for the reader.

4. Before-and-after

To measure the effect of responsive images on mobile, I ran Lighthouse five times in Chrome's incognito mode. The performance metrics that improved were Large Contentful Paint and Total Blocking Time. Meanwhile, Cumulative Layout Shift and Speed Index worsened.

Before After
First Contentful Paint (s) 3.2 3.2
Largest Contentful Paint (s) 11.3 5.3
Total Blocking Time (ms) 804 458
Cumulative Layout Shift 0.026 0.111
Speed Index (s) 3.2 6.9

We can also see a difference in how Vite builds the app. Before, the image assets lived in dist/assets/projects, mirroring the file structure in public/assets. (For simplicity, I didn't include hashes in the file names below.)

my-app
└── dist
    ├── @embroider
    ├── assets
    │   ├── projects
    │   │   ├── file-1.jpg
    │   │   ├── file-2.jpg
    │   │   ├── file-3.png
    │   │   └── file-4.webp
    │   ├── -embroider-route-entrypoint-projects.js
    │   └── main.js
    └── index.html
Enter fullscreen mode Exit fullscreen mode

Now, @responsive-image/vite places the files in dist/assets (one for each width and output format).

my-app
└── dist
    ├── @embroider
    ├── assets
    │   ├── -embroider-route-entrypoint-projects.js
    │   ├── file-1-480w.webp
    │   ├── file-1-960w.webp
    │   ├── file-2-480w.webp
    │   ├── file-2-960w.webp
    │   ├── file-3-480w.webp
    │   ├── file-3-960w.webp
    │   ├── file-4-480w.webp
    │   ├── file-4-960w.webp
    │   ├── main.js
    │   └── tracking.js
    └── index.html
Enter fullscreen mode Exit fullscreen mode

Interestingly, the main.js in dist/assets (gzip: 87.95 kB) got split into main.js (36.40 kB) and tracking.js (53.97 kB). I'm unsure what had caused this split.

5. Conclusion

Thanks to responsive-image and Vite's glob import, we can continue to easily render responsive images in Ember apps. What changes need to be made is clear thanks to separation of concerns.

However, based on the performance metrics, how much benefit responsive images brought to my Projects page is not so clear. I do want to keep the addon around for a while and further optimize the app. Ideas are welcome.

My real-life example concerned "local images," i.e. they are provided by the app. For more information on "remote images" (provided by a CDN) and other key concepts, you can visit the documentation site for responsive-image.

Top comments (0)