DEV Community

Cover image for Eliminating CLS when using SSR for viewport specific responsive designs
Craig Morten
Craig Morten

Posted on • Originally published at Medium

Eliminating CLS when using SSR for viewport specific responsive designs

A common gotcha when building some responsive sites is that the desired designs for different viewports are sometimes quite different. In many cases, utilising CSS media queries is sufficient to be able to cater for these differences, but there are occasions where this isn’t necessarily sufficient on it’s own. It is also not always a pragmatic approach to refuse to implement such designs!

A stack of engineering books, a phone, and a plant. The top book is titled  “Stunning CSS”. Photo by KOBU Agency on Unsplash.

CSS only gets you so far

For example, take the following markup for a ficticious article component where on desktop information about the author is contained within a slidedown from the top of the component, and on mobile it is inside a static section at the bottom:

<Article>
  <ArticleAuthorInformationDesktop />
  <ArticleImage />
  <ArticleHeading />
  <ArticlePreviewText />
  <ArticleAuthorInformationMobile />
</Article>
Enter fullscreen mode Exit fullscreen mode

Now if the markup used for the “top” desktop and the “bottom” mobile author information components were exactly the same you could instead opt to make use of CCS grid or the CSS flexbox order to simply have the component once in markup, and use CSS media queries to set the appropriate CSS rule to position the component as desired for the different viewports.

However, this comes with caveats:

“Use of the order property has exactly the same implications for accessibility as changing the direction with flex-direction. Using order changes the order in which items are painted, and the order in which they appear visually. It does not change the sequential navigation order of the items. Therefore if a user is tabbing between the items, they could find themselves jumping around your layout in a very confusing way.” — https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout/Ordering_flex_items#the_order_property_and_accessibility

If we were to be clever here and try to achieve the desired differences in layout entirely with CSS, we would be introducing potential accessibility (a11y) issues as the experience for visual and non-visual users would differ.

In theory this can be worked around with either clever use of tabindex and / or aria-owns where in either case you can identify the order in which elements are expected to be traversed — however both cases are almost always strongly advised against. It is risky to try to hijack the default tab order of a page, and aria-owns is not supported by VoiceOver meaning it’s use would only resolve the issue for non fruit related devices. Both options are incredibly fragile to changes to associated components where there is a real maintenance overhead to ensuring this is never regressed.

This also all falls apart if the desktop and mobile versions of the component need to be completely different, e.g. a slidedown vs just a static content section — you can’t use CSS to change HTML markup! This means you will almost certainly be needing different components and then either not rendering or hiding the one that doesn’t fit the viewport.

Server-side rethinkings

This is fine right? Well, it is until you start server-side rendering (SSR) this article component. The server doesn’t have a concept of a viewport out of the box, so there is no way to know which of the two variations will need to be in the HTML response that you’re sending to your users’ browsers.

Laptop with a CSS file opened in a code editor. Photo by Jantine Doornbos on Unsplash.

In the absence of viewport details on the server when rendering, this means:

  1. You can guess (ahem, I mean “adopt mobile-first”), but this will inevitably result in some sort of flash of unstyled content (FOUC) or cumulative layout shift (CLS) whenever you guess wrong, which is a bit naff!
  2. You can choose to not render either server-side, but then you will almost definitely get a CLS when the component is hydrated client-side, or best case it will pop in late which is annoying.
  3. You can choose to render both, but then there’s the same issues as above where one of the variations is there on page load and then get’s removed shortly after either looking visually poor and potentially causing a CLS.

This isn’t necessarily a new nor unsolved problem. Aside from the options above, one idea is to make use of the Client Hints to provide your server with context of where this component will be rendered so an appropriate choice can be made. Alternatively a similar flow can be achieved with “user-agent sniffing” through packages such as ua-parse-js where you detect the user-agent header and from that deduce a likely device type and size.

Be sure to take care that you include the source of the hint in any cache-keys you might have otherwise you might cache poison if you render differently for different viewports!

Lastly, perhaps my preferred solution is (1) to use one of the above hint techniques if possible (and reliable) and then (2) do the following:

  1. If you have a trusted hint then render only what is needed in your HTML and stop here.
  2. Otherwise server-side render both variations of the component in your HTML response.
  3. In addition to this double render, add a small snippet of CSS and appropriate styles / classes to the components with CSS media queries ensuring that client-side they are only displayed if the viewport matches.
  4. Upon hydration you can let JS take over, remove the component from the DOM which isn’t required for the viewport and strip the “SSR smoothing over CSS” attributes as they are no longer required.

If you want to see an example implementation of this idea check out the @artsy/fresnel package (and I’m sure there are others).

How can I do this myself?

It’s not always appropriate to start pulling in more packages to achieve a goal, certainly if you’re keeping performance in mind (with the likes of bundlephobia), and said package may be doing more than you need.

I was in this situation recently — the project was already using react-responsive, a wrapper around the matchMedia API with some server-side options for fallbacks, which is only useful if there is a sensible default (uncommon) or if you are using hints to be able to pass through to the package. This doesn’t do enough as I didn’t have hints to work with and pass to the package, so more work was needed to enable CLS-less SSR.

Here’s the first pass solution I settled on for my use-case (subject to change, and mileage may vary):

// index.tsx

import React, { useEffect, useState } from 'react';
import { useMediaQuery } from 'react-responsive';
import { BREAKPOINTS, BreakpointWidth } from './breakpoints';
import styles from './index.css';

interface SSRMediaQueryProps {
  maxWidth?: BreakpointWidth;
  minWidth?: BreakpointWidth;
}

interface DisplayValueProps extends SSRMediaQueryProps {
  breakpoint: BreakpointWidth;
}

const getDisplayValue = ({ breakpoint, maxWidth, minWidth }: DisplayValueProps) => {
  if (typeof maxWidth === 'undefined' && typeof minWidth === 'undefined') {
    return undefined;
  } else if (typeof minWidth === 'undefined') {
    return breakpoint >= maxWidth! ? 'none' : undefined;
  } else if (typeof maxWidth === 'undefined') {
    return breakpoint < minWidth ? 'none' : undefined;
  }

  return breakpoint < minWidth || breakpoint >= maxWidth ? 'none' : undefined;
};

/**
 * This component serves to allow viewport specific components be server-side
 * rendered without causing a CLS.
 */
export const SSRMediaQuery: React.FC<SSRMediaQueryProps> = ({ children, maxWidth, minWidth }) => {
  const [isClientSide, setIsClientSide] = useState(false);
  const isMatch = useMediaQuery({ maxWidth, minWidth });

  useEffect(() => {
    setIsClientSide(true);

    return () => {
      setIsClientSide(false);
    };
  }, []);

  if (!isClientSide) {
    const style = {
      '--xs': getDisplayValue({ breakpoint: BREAKPOINTS.xs, maxWidth, minWidth }),
      '--sm': getDisplayValue({ breakpoint: BREAKPOINTS.sm, maxWidth, minWidth }),
      '--md': getDisplayValue({ breakpoint: BREAKPOINTS.md, maxWidth, minWidth }),
      '--lg': getDisplayValue({ breakpoint: BREAKPOINTS.lg, maxWidth, minWidth }),
      '--xl': getDisplayValue({ breakpoint: BREAKPOINTS.xl, maxWidth, minWidth }),
    } as React.CSSProperties;

    return (
      <div className={styles.mediaQueryContainer} style={style}>
        {children}
      </div>
    );
  }

  if (isMatch) {
    return <>{children}</>;
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

Where the imported CSS file is roughly (I’m using SCSS mixins to avoid writing long-hand!):

/* index.css */

@media (max-width: 543px) {
  .mediaQueryContainer {
    display: var(--xs, unset);
  }
}
@media (min-width: 544px) and (max-width: 767px) {
  .mediaQueryContainer {
    display: var(--sm, unset);
  }
}
@media (min-width: 768px) and (max-width: 991px) {
  .mediaQueryContainer {
    display: var(--md, unset);
  }
}
@media (min-width: 992px) and (max-width: 1199px) {
  .mediaQueryContainer {
    display: var(--lg, unset);
  }
}
@media (min-width: 1200px) {
  .mediaQueryContainer {
    display: var(--xl, unset);
  }
}
Enter fullscreen mode Exit fullscreen mode

And the usage is something like:

<Article>
  <SSRMediaQuery minWidth={BREAKPOINTS.sm}>
    <ArticleAuthorInformationDesktop />
  </SSRMediaQuery>
  <ArticleImage />
  <ArticleHeading />
  <ArticlePreviewText />
  <SSRMediaQuery maxWidth={BREAKPOINTS.sm}>
    <ArticleAuthorInformationMobile />
  </SSRMediaQuery>
</Article>
Enter fullscreen mode Exit fullscreen mode

Walking through the implementation:

  1. We first assume that we are always in a server-side or pre-hydration world until an effect (which only runs client-side) tells us otherwise.
  2. In this server-side world we always render the provided children, and furthermore, do so with them wrapped in a <div> that has appropriate styles associated with it to ensure it is only displayed to users for on the desired viewports. Here I was feeling fancy so made use of CSS variables to determine how the display value is set for the wrapper <div>, defaulting to unset if no value is passed.
  3. Once we hit the client we can be safe in the knowledge that the CSS media queries on the wrapper <div> will kick in meaning that although we’ve taken a slight performance hit shipping both variations of component, we don’t suffer from and visual quirks or CLS.
  4. Post hydration we’ll still be in a state where both variations exist — indeed both variations will render on first pass so care is needed to make sure your components don’t have “onload / onmount” like side effects which could “double fire”. (For further reading on why we do this double pass see this GitHub issue. There is a better alternative which is more involved that I’m not covering here – this uses suspense boundaries and manual pruning of the DOM to remove the unwanted tree prior to hydration).
  5. Following the first render the effect will fire, this will update the state to switch to the client-side mode. This triggers a re-render where we either continue to render the children as there is a viewport match, or nothing otherwise.

And just like that we’re free of CLS and can gracefully handle this kind of markup difference between different viewports. 🎉

Parting thoughts

The unfortunate state is that there is no perfect answer here, just trade-offs:

  • Get exactly what you want, but having to trust client hints which might not always be there... so maybe only sometimes getting what you want?
  • Have faster time to first byte (TTFB) and other similar page load metrics at the cost of having an annoying CLS vs almost certainly having CLS at the cost of page weight bloat slower that first impression.
  • Potential search engine optimisation (SEO) gotchas as a result of double rendering content (although it appears generally accepted that you shouldn’t get penalised for this kind of behaviour) coupled with the performance issues above for core web vitals having a direct impact on SEO.
  • Care still need for side-effects in SSR double rendered components (unless you put in more work).

Always evaluate what works for your use-case!

  • If you’re in an SPA world where you only client-render, this article was not for you!
  • If you are in a SSR world but the component that can be rendered differently is only rendered upon customer interaction or some other lazy or deferred trigger, then you don’t need to worry — just use client-side techniques.
  • If you’re in a SSR world, maybe consider some of the techniques discussed.

Hopefully this can save some research and thoughts for others in future. If you have any ideas, suggestions, or great ways (or packages) you use to solve this problem I’d love to hear it in the comments!

Like what I have to say? Consider a follow here or on Twitter.

Top comments (0)