An SSR compatible approach to CSS media query based responsive layouts for React.
An SSR compatible approach to CSS media query based responsive layouts for React.
[![CircleCI][ci-icon]][ci] [![npm version][npm-icon]][npm]
The Fresnel equations describe the reflection of light when incident on an interface between different optical media.
– https://en.wikipedia.org/wiki/Fresnel_equations
# React 18+
yarn add @artsy/fresnel
# React 17
yarn add @artsy/fresnel@6
Table of Contents
When writing responsive components it's common to use media queries to adjust the display when certain conditions are met. Historically this has taken place directly in CSS/HTML:
@media screen and (max-width: 767px) {
.my-container {
width: 100%;
}
}
@media screen and (min-width: 768px) {
.my-container {
width: 50%;
}
}
`)
})
app.listen(3000, () => {
console.warn("\nApp started at http://localhost:3000 \n")
})
And that's it! To test, disable JS and scale your browser window down to a mobile size and reload; it will correctly render the mobile layout without the need to use a user-agent or other server-side "hints".
@artsy/fresnel works great with Gatsby or Next.js's static hybrid approach to
rendering. See the examples below for a simple implementation.
There are four examples one can explore in the /examples folder:
While the Basic and SSR examples will get one pretty far, @artsy/fresnel
can do a lot more. For an exhaustive deep-dive into its features, check out the
Kitchen Sink app.
If you're using Gatsby, you can also try gatsby-plugin-fresnel for easy configuration.
Other existing solutions take a conditionally rendered approach, such as
[react-responsive][react-responsive] or [react-media][react-media], so where
does this approach differ?
Server side rendering!
But first, what is conditional rendering?
In the React ecosystem a common approach to writing declarative responsive
components is to use the browser’s [matchMedia api][match-media-api]:
{({ sm }) => {
if (sm) {
return
} else {
return
}
}}
On the client, when a given breakpoint is matched React conditionally renders a tree.
However, this approach has some limitations for what we wanted to achieve with our server-side rendering setup:
It's impossible to reliably know the user's current breakpoint during the server render phase since that requires a browser.
Setting breakpoint sizes based on user-agent sniffing is prone to errors due the inability to precisely match device capabilities to size. One mobile device might have greater pixel density than another, a mobile device may fit multiple breakpoints when taking device orientation into consideration, and on desktop clients there is no way to know at all. The best devs can do is guess the current breakpoint and populate `` with assumed state.
Artsy settled on what we think makes the best trade-offs. We approach this problem in the following way:
Render markup for all breakpoints on the server and send it down the wire.
The browser receives markup with proper media query styling and will immediately start rendering the expected visual result for whatever viewport width the browser is at.
When all JS has loaded and React starts the rehydration phase, we query the browser for what breakpoint it’s currently at and then limit the rendered components to the matching media queries. This prevents life-cycle methods from firing in hidden components and unused html being re-written to the DOM.
Additionally, we register event listeners with the browser to notify the
MediaContextProvider when a different breakpoint is matched and then
re-render the tree using the new value for the onlyMatch prop.
Let’s compare what a component tree using matchMedia would look like with our
approach:
BeforeAfter
{({ sm }) => {
if (sm) return
else return
}}
<>
See the server-side rendering app for a working example.
First things first. You’ll need to define the breakpoints and interaction needed for your design to produce the set of media components you can use throughout your application.
For example, consider an application that has the following breakpoints:
sm.md.lg.xl.And the following interactions:
hover.notHover.You would then produce the set of media components like so:
// Media.tsx
const ExampleAppMedia = createMedia({
breakpoints: {
sm: 0,
md: 768,
lg: 1024,
xl: 1192,
},
interactions: {
hover: "(hover: hover)",
notHover: "(hover: none)",
landscape: "not all and (orientation: landscape)",
portrait: "not all and (orientation: portrait)",
},
})
export const { Media, MediaContextProvider, createMediaStyle } = ExampleAppMedia
As you can see, breakpoints are defined by their start offset, where the first one is expected to start at 0.
The MediaContextProvider component influences how Media components will be
rendered. Mount it at the root of your component tree:
import React from "react"
import { MediaContextProvider } from "./Media"
export const App = () => {
return ...
}
The Media component created for your application has a few mutually exclusive
props that make up the API you’ll use to declare your responsive layouts. These
props all operate based on the named breakpoints that were provided when you
created the media components.
import React from "react"
import { Media } from "./Media"
export const HomePage = () => {
return (
<>
Hello mobile!
Hello desktop!
)
}
The examples given for each prop use breakpoint definitions as defined in the above ‘Setup’ section.
If you would like to avoid the underlying div that is generated by `` and instead use your own element, use the render-props form but be sure to not render any children when not necessary:
export const HomePage = () => {
return (
<>
Hello mobile!
{(className, renderChildren) => {
return (
{renderChildren ? "Hello desktop!" : null}
)
}}
)
}
Note: This is only used when SSR rendering
Besides the Media and MediaContextProvider components, there's a
createMediaStyle function that produces the CSS styling for all possible media
queries that the Media instance can make use of while markup is being passed
from the server to the client during hydration. If only a subset of breakpoint
keys is used those can be optional specified as a parameter to minimize the
output. Be sure to insert this within a tag [in your document’s](https://github.com/artsy/fresnel/blob/main/examples/ssr-rendering/src/server.tsx#L28).
It’s advisable to do this setup in its own module so that it can be easily imported throughout your application:
import { createMedia } from "@artsy/fresnel"
const ExampleAppMedia = createMedia({
breakpoints: {
sm: 0,
md: 768,
lg: 1024,
xl: 1192,
},
})
// Generate CSS to be injected into the head
export const mediaStyle = ExampleAppMedia.createMediaStyle() // optional: .createMediaStyle(['at'])
export const { Media, MediaContextProvider } = ExampleAppMedia
Rendering can be constrained to specific breakpoints/interactions by specifying a list of media queries to match. By default all will be rendered.
By default, when rendered client-side, the browser’s [matchMedia
api][match-media-api] will be used to further constrain the onlyMatch list
to only the currently matching media queries. This is done to avoid triggering
mount related life-cycle hooks of hidden components.
Disabling this behaviour is mostly intended for debugging purposes.
Use this to declare that children should only be visible at a specific breakpoint, meaning that the viewport width is greater than or equal to the start offset of the breakpoint, but less than the next breakpoint, if one exists.
For example, children of this Media declaration will only be visible if the
viewport width is between 0 and 768 (768 not included) points:
...
The corresponding css rule:
@media not all and (min-width: 0px) and (max-width: 767px) {
.fresnel-at-sm {
display: none !important;
}
}
Use this to declare that children should only be visible while the viewport width is less than the start offset of the specified breakpoint.
For example, children of this Media declaration will only be visible if the
viewport width is between 0 and 1024 (1024 not included) points:
...
The corresponding css rule:
@media not all and (max-width: 1023px) {
.fresnel-lessThan-lg {
display: none !important;
}
}
Use this to declare that children should only be visible while the viewport width is equal or greater than the start offset of the next breakpoint.
For example, children of this Media declaration will only be visible if the
viewport width is equal or greater than 1024 points:
...
The corresponding css rule:
@media not all and (min-width: 1024px) {
.fresnel-greaterThan-md {
display: none !important;
}
}
Use this to declare that children should only be visible while the viewport width is equal to the start offset of the specified breakpoint or greater.
For example, children of this Media declaration will only be visible if the
viewport width is 768 points or up:
...
The corresponding css rule:
@media not all and (min-width: 768px) {
.fresnel-greaterThanOrEqual-md {
display: none !important;
}
}
Use this to declare that children should only be visible while the viewport width is equal to the start offset of the first specified breakpoint but less than the start offset of the second specified breakpoint.
For example, children of this Media declaration will only be visible if the
viewport width is between 768 and 1192 (1192 not included) points:
...
The corresponding css rule:
@media not all and (min-width: 768px) and (max-width: 1191px) {
.fresnel-between-md-xl {
display: none !important;
}
}
Pros:
Cons:
No open issues yet, or sync has not completed.