Table of Contents
- Responsive CSS
- Responsive Props
- Responsive Prop Patterns
- A Note About SSR
- Summary
- Responsive Prop Libraries
Responsive CSS
Many solutions exist for writing responsive CSS in React.
- CSS-in-JS (Emotion and styled-components are two popular options)
- Tailwind CSS
- CSS Modules
If you feel like you need to use one, I recommend choosing something which supports reusable media queries. You don't want to be hard-coding breakpoint values all throughout your code (it's tedious to write, error prone, and difficult to change).
But you might not need to write any responsive CSS.
Responsive Props
Responsive CSS in React has a glaring weakness: it doesn't allow you to responsively change the values of props. Instead of writing CSS wrapped in media queries, responsive props is a method where you specify the value of the prop for predefined breakpoints and logic exists somewhere that chooses the value associated with the active breakpoint.
Why would you want to do that? Let's say you have different button sizes: large, medium, small. You probably don't want all buttons to change size responsively the same way. You might want one button to be small in mobile layouts while another is medium. So instead of responsive CSS, what you really want is responsive props.
We'll introduce some patterns below which primarily differ in two ways:
- How the different prop values are specified
- Where the logic exists for choosing the correct value
The code samples shown in this blog post are copied from a sample app that has working examples of all the patterns.
Responsive Prop Patterns
The foundation of responsive props in react is knowing whether a given media query (aka breakpoint) is active. Let's write a custom hook for that called useMediaQuery()
.
import { useEffect, useState } from "react";
/**
* Custom hook that tells you whether a given media query is active.
*
* Inspired by https://usehooks.com/useMedia/
* https://gist.github.com/gragland/ed8cac563f5df71d78f4a1fefa8c5633
*/
export default function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(
() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (event) => setMatches(event.matches);
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
},
[] // Empty array ensures effect is only run on mount and unmount
);
return matches;
}
It can be used like this:
const isActive = useMediaQuery('(max-width: 640px)');
But remember we don't want to be littering our code with breakpoint values so let's create another hook which returns booleans for all of our defined breakpoints. We'll call it useBreakpoints()
.
import useMediaQuery from "./useMediaQuery";
/**
* Get a set of boolean representing which breakpoint is active
* and which breakpoints are inactive.
*
* Inspired by: https://github.com/contra/react-responsive/issues/162#issuecomment-592082035
*/
export default function useBreakpoints() {
const breakpoints = {
isXs: useMediaQuery("(max-width: 640px)"),
isSm: useMediaQuery("(min-width: 641px) and (max-width: 768px)"),
isMd: useMediaQuery("(min-width: 769px) and (max-width: 1024px)"),
isLg: useMediaQuery("(min-width: 1025px)"),
active: "xs"
};
if (breakpoints.isXs) breakpoints.active = "xs";
if (breakpoints.isSm) breakpoints.active = "sm";
if (breakpoints.isMd) breakpoints.active = "md";
if (breakpoints.isLg) breakpoints.active = "lg";
return breakpoints;
}
It's used like this:
const {isXs, isSm, isMd, isLg, active} = useBreakpoints();
Those hooks can power all of the responsive prop patterns discussed below.
Conditional Rendering
Conditional rendering is the pattern of specifying content that gets rendered at the different breakpoints. We can accomplish that in two different ways.
Conditional Rendering with a Hook
We can use the useBreakpoints()
hook from above to do conditional rendering like this:
const {isXs, isSm} = useBreakpoints();
return isXs || isSm ? <Button size="small" /> : <Button />;
Conditional Rendering with a Component
We could also write a component which will do something similar for us. Let's call it <Breakpoint>
.
import useBreakpoints from "./useBreakpoints";
export default function Breakpoint({ at, children }) {
if (!at) {
console.error("<Breakpoint>: must specify a breakpoint for the `at` prop.");
}
const { active } = useBreakpoints();
return active === at ? children : null;
}
Then we can use it like this:
return (<>
<Breakpoint at="xs">
<Button size="small">Button</Button>
</Breakpoint>
<Breakpoint at="sm">
<Button size="small">Button</Button>
</Breakpoint>
<Breakpoint at="md">
<Button>Button</Button>
</Breakpoint>
<Breakpoint at="lg">
<Button size="large">Button</Button>
</Breakpoint>
</>);
In its naive form, the component version of this pattern can be quite verbose. fresnel is a library which uses this pattern and provides additional props such as greaterThan
and between
which can decrease the amount of code you need to write.
Notice that with conditional rendering, we're not changing the value of props so much as changing what gets rendered. There are situations where that's exactly what we need, such as choosing whether to render the mobile or desktop menu.
return isXs || isXm ? <Mobile /> : <Desktop />;
As shown with the button example above, the conditional rendering pattern doesn't fit as well when we just want to make small tweaks such as changing the size or position of components. For those situations, we have other patterns that just modify props.
Breakpoint Props
Maybe we could have one prop for each breakpoint. So instead of just size
we have sizeXs
, sizeSm
and so on. It would be used like this:
<Button sizeXs="small" sizeSm="small" sizeMd="medium" sizeLg="large">Button</Button>
In terms of usage, that's quite a bit less verbose than the example for conditional rendering. What about the implementation?
In the naive form, the implementation of this is very verbose.
import styles from "../Button.module.css";
import useBreakpoints from "../useBreakpoints";
const defaultSize = "";
const defaultColor = "#eee";
export default function ButtonNaive({
sizeXs,
sizeSm,
sizeMd,
sizeLg,
colorXs,
colorSm,
colorMd,
colorLg,
children
}) {
const { isXs, isSm, isMd, isLg } = useBreakpoints();
let activeSize = defaultSize;
let activeColor = defaultColor;
if (isXs) {
activeSize = sizeXs;
activeColor = colorXs;
} else if (isSm) {
activeSize = sizeSm;
activeColor = colorSm;
} else if (isMd) {
activeSize = sizeMd;
activeColor = colorMd;
} else if (isLg) {
activeSize = sizeLg;
activeColor = colorLg;
}
const buttonClasses = [styles.base];
if (styles[activeSize]) {
buttonClasses.push(styles[activeSize]);
}
return (
<button
className={buttonClasses.join(" ")}
style={{ backgroundColor: activeColor }}
>
{children}
</button>
);
}
We can do much better with a dynamic lookup for the props.
import styles from "../Button.module.css";
import useBreakpoints from "../useBreakpoints";
const defaultSize = "";
const defaultColor = "#eee";
export default function DynamicButton({ children, ...props }) {
const { active } = useBreakpoints();
// The active breakpoint comes out lowercase but for the props
// the first letter of the breakpoint needs to be capitalized.
const activeCapitalized = active[0].toUpperCase() + active[1];
// Now we dynamically lookup the value of each responsive prop
// according to the active breakpoint.
const activeSize = props[`size${activeCapitalized}`] || defaultSize;
const activeColor = props[`color${activeCapitalized}`] || defaultColor;
const buttonClasses = [styles.base];
if (styles[activeSize]) {
buttonClasses.push(styles[activeSize]);
}
return (
<button
className={buttonClasses.join(" ")}
style={{ backgroundColor: activeColor }}
>
{children}
</button>
);
}
That's a little better but there's still more logic than we'd like to have in our components, so let's move some of it into a hook.
const defaultSize = "";
const defaultColor = "#eee";
function useResponsiveProp(props, propName, defaultValue) {
const { active } = useBreakpoints();
const activeCapitalized = active[0].toUpperCase() + active[1];
return props[`${propName}${activeCapitalized}`] || defaultValue;
}
export default function DynamicButton({ children, ...props }) {
const activeSize = useResponsiveProp(props, 'size', defaultSize);
const activeColor = useResponsiveProp(props, 'color', defaultColor);
const buttonClasses = [styles.base];
if (styles[activeSize]) {
buttonClasses.push(styles[activeSize]);
}
return (
<button
className={buttonClasses.join(" ")}
style={{ backgroundColor: activeColor }}
>
{children}
</button>
);
}
If you still think that's too much logic for dumb presentation components then you could also create an HOC.
export default MakeResponsive(Button, ["size", "color"]);
function MakeResponsive(WrappedComponent, responsiveProps = []) {
function MakeResponsiveWrapper(props) {
const { active } = useBreakpoints();
const activeCapitalized = active[0].toUpperCase() + active[1];
const modifiedProps = { ...props };
// Process the responsive props to extract responsive values
for (const prop of responsiveProps) {
const breakpointProp = `${prop}${activeCapitalized}`;
if (props[breakpointProp]) {
modifiedProps[prop] = props[breakpointProp];
}
}
return <WrappedComponent {...modifiedProps} />;
}
MakeResponsiveWrapper.displayName = `MakeResponsive(${
WrappedComponent.displayName || WrappedComponent.name
})`;
return MakeResponsiveWrapper;
}
That lets our components be dumb again, but now we have an HOC ๐ฌ.
Object of Props
The same code which inspired the useMediaQuery()
hook also introduced me to a new pattern: specifying values for each breakpoint and letting a hook choose those values.
useBreakpointValues()
We can use the useBreakpoints()
hook to create another hook called useBreakpointValues()
which accepts a map of breakpoints and values and returns the value for the breakpoint that is currently active.
function useBreakpointValues(breakpointValues) {
const { active } = useBreakpoints();
return breakpointValues[active];
}
We could use that inside our components to make any prop accept responsive values.
const defaultColors = {
xs: "#eee",
sm: "#eee",
md: "#eee",
lg: "#eee"
};
export default function Button({ size, color = defaultColors, children }) {
const appliedSize = useBreakpointValues(size);
const appliedColor = useBreakpointValues(color);
const buttonClasses = [styles.base];
if (styles[appliedSize]) {
buttonClasses.push(styles[appliedSize]);
}
return (
<button
className={buttonClasses.join(" ")}
style={{ backgroundColor: appliedColor }}
>
{children}
</button>
);
}
But that makes our component more complicated. I recommend keeping the component simple and using the hook outside of the component.
const currentSize = useBreakpointValues({
xs: 'small',
sm: 'small',
md: 'medium',
lg: 'large'
});
return <Button size={currentSize}>Button</Button>;
Using this pattern, our components can remain dumb presentation components by moving the breakpoint logic into a custom hook that we use outside of the components.
Component
We could also build a component version of useBreakpointValues()
which uses render props to create a responsive prop pattern I learned from instructure-ui.
import useBreakpointValues from "./useBreakpointValues";
export default function Responsive({ props, children, render }) {
const appliedProps = useBreakpointValues(props);
if (!(children || render)) {
console.error("<Responsive> must be given a render prop or children prop.");
}
const renderFn = children || render;
return renderFn(appliedProps);
}
We would use it like this:
return (
<Responsive props={{
xs: 'small',
sm: 'small',
md: 'medium',
lg: 'large'
}}>
{(size) => <Button size={size}>Button</Button>}
</Responsive>
);
Why would you want to do that instead of the hook? Perhaps just personal preference. And when you have responsive props for multiple components, it could help avoid the need for coming up with unique names. You can see this in the following contrived example using different button sizes for three buttons. Using the <Responsive>
component is a little more verbose, and the render props pattern is pretty awkward to type, but maybe you just really dislike coming up with creative variable names.
const size1 = useBreakpointValues({...});
const size2 = useBreakpointValues({...});
const size3 = useBreakpointValues({...});
return (
<div>
<Button size={size1}>Button 1</Button>
<Button size={size2}>Button 2</Button>
<Button size={size3}>Button 3</Button>
</div>
);
// Here's the same setup using <Responsive>
return (
<div>
<Responsive props={{...}}>
{(size) => <Button size={size}>Button 1</Button>}
</Responsive>
<Responsive props={{...}}>
{(size) => <Button size={size}>Button 2</Button>}
</Responsive>
<Responsive props={{...}}>
{(size) => <Button size={size}>Button 3</Button>}
</Responsive>
</div>
);
HOC for Responsive Props
Maybe neither of those are quite what you're looking for because you really like HOCs. We can do that too.
Usage:
<ButtonHOC size={{ xs: "small", sm: "small", md: "medium", lg: "large" }}>
Button
</ButtonHOC>
Implementation:
function MakeResponsive(WrappedComponent, responsiveProps = []) {
function MakeResponsiveWrapper(props) {
const { active } = useBreakpoints();
const modifiedProps = { ...props };
// Process the responsive props to extract responsive values
for (const prop of responsiveProps) {
if (props[prop]) {
modifiedProps[prop] = props[prop][active];
}
}
return <WrappedComponent {...modifiedProps} />;
}
MakeResponsiveWrapper.displayName = `MakeResponsive(${
WrappedComponent.displayName || WrappedComponent.name
})`;
return MakeResponsiveWrapper;
}
const ButtonHOC = MakeResponsive(Button, ['size','color']);
Again, our component stays dumb while the HOC makes it smarter.
TIP: If you dislike having to specify a value for each breakpoint, even when each breakpoint doesn't have a distinct value, then you could add some logic to
useBreakpointValues()
so that if a breakpoint value isn't specified it just uses the value for the breakpoint below it. Then you would only have to specify when the value changes. Example:{xs: 'small', md: 'large'}
.
Array of Props
If you like how the object props pattern moves the logic outside of the component but dislike having to specify the breakpoints each time by name, then you may like this variation where props are specified via an array.
const size = useBreakpointValues([ "small", "small", "medium", "large" ]);
return <Button size={size}>Button</Button>;
// Or...
<Button size={[ "small", "small", "medium", "large" ]}>
Button
</Button>
The downside of this pattern is that it's not explicit; e.g. it's not immediately clear which value is associated with which breakpoint.
A Note About SSR
There is no screen on the server so no breakpoints will be active. The best way to handle this situation is to choose a breakpoint that is active by default. Make this choice carefully because it could impact SEO (particularly for search engines that don't execute JavaScript).
Summary
A few patterns exist for responsive props in React. While choosing which patterns to use, consider these characteristics.
How props are specified | Where the logic exists for choosing the active value | ||
---|---|---|---|
Conditional Rendering | Separately in each instance | Outside the component | |
Breakpoint Props | Naive | One prop for each breakpoint | Inside the component |
Dynamic | One prop for each breakpoint | Inside the component | |
Dynamic Hook | One prop for each breakpoint | Inside a hook | |
HOC | One prop for each breakpoint | Inside an HOC | |
Object of Props | Hook | An object | In the component or in a hook |
Render-prop Component | An object | In the render-prop component | |
HOC | An object | In the HOC | |
Array of Props | Hook | An array | In the component or in a hook |
Render-prop Component | An array | In the render-prop component | |
HOC | An array | In the HOC |
My preference is to use the Object Props pattern with the useBreakpointValue()
hook and <Responsive>
component because I like the explicit nature of the props object and I like having the logic for choosing the active breakpoint value outside of my components.
What do you think? Which pattern do you like? Are there responsive prop patterns I didn't include? If you're not using responsive props, do you feel like you should? Let me know in the comments. Thanks for reading!
Responsive Prop Libraries
Conditional Rendering
- fresnel
- react-responsive
- react-media was an early player in this game. Unfortunately the library hasn't been updated for hooks.
Breakpoint Props
Object of Props
- Responsive instructure-ui component
- responsive-props - an HOC that adds responsive props to styled components.
Top comments (2)
So complicated topic.
Good job!