1 | # @artsy/fresnel
|
2 |
|
3 | [![CircleCI][ci-icon]][ci] [![npm version][npm-icon]][npm]
|
4 |
|
5 | > The Fresnel equations describe the reflection of light when incident on an
|
6 | > interface between different optical media.
|
7 |
|
8 | – https://en.wikipedia.org/wiki/Fresnel_equations
|
9 |
|
10 | ## Installation
|
11 |
|
12 | ```sh
|
13 | yarn add @artsy/fresnel
|
14 | ```
|
15 |
|
16 | **Table of Contents**
|
17 |
|
18 | - [Overview](#overview)
|
19 | - [Basic Example](#basic-example)
|
20 | - [Server-side Rendering (SSR)](#server-side-rendering-ssr-usage)
|
21 | - [Usage with Gatsby or Next](#usage-with-gatsby-or-next)
|
22 | - [Example Apps](#example-apps)
|
23 | - [Why not conditionally render?](#why-not-conditionally-render)
|
24 | - [API](#api)
|
25 | - [Pros vs Cons](#pros-vs-cons)
|
26 | - [Development](#development)
|
27 |
|
28 | ## Overview
|
29 |
|
30 | When writing responsive components it's common to use media queries to adjust
|
31 | the display when certain conditions are met. Historically this has taken place
|
32 | directly in CSS/HTML:
|
33 |
|
34 | ```css
|
35 | @media screen and (max-width: 767px) {
|
36 | .my-container {
|
37 | width: 100%;
|
38 | }
|
39 | }
|
40 | @media screen and (min-width: 768px) {
|
41 | .my-container {
|
42 | width: 50%;
|
43 | }
|
44 | }
|
45 | ```
|
46 |
|
47 | ```html
|
48 | <div class="my-container" />
|
49 | ```
|
50 |
|
51 | By hooking into a breakpoint definition, `@artsy/fresnel` takes this declarative
|
52 | approach and brings it into the React world.
|
53 |
|
54 | ## Basic Example
|
55 |
|
56 | ```tsx
|
57 | import React from "react"
|
58 | import ReactDOM from "react-dom"
|
59 | import { createMedia } from "@artsy/fresnel"
|
60 |
|
61 | const { MediaContextProvider, Media } = createMedia({
|
62 | breakpoints: {
|
63 | sm: 0,
|
64 | md: 768,
|
65 | lg: 1024,
|
66 | xl: 1192,
|
67 | },
|
68 | })
|
69 |
|
70 | const App = () => (
|
71 | <MediaContextProvider>
|
72 | <Media at="sm">
|
73 | <MobileApp />
|
74 | </Media>
|
75 | <Media at="md">
|
76 | <TabletApp />
|
77 | </Media>
|
78 | <Media greaterThanOrEqual="lg">
|
79 | <DesktopApp />
|
80 | </Media>
|
81 | </MediaContextProvider>
|
82 | )
|
83 |
|
84 | ReactDOM.render(<App />, document.getElementById("react"))
|
85 | ```
|
86 |
|
87 | ## Server-side Rendering (SSR) Usage
|
88 |
|
89 | The first important thing to note is that when server-rendering with
|
90 | `@artsy/fresnel`, all breakpoints get rendered by the server. Each `Media`
|
91 | component is wrapped by plain CSS that will only show that breakpoint if it
|
92 | matches the user's current browser size. This means that the client can
|
93 | accurately start rendering the HTML/CSS while it receives the markup, which is
|
94 | long before the React application has booted. This improves perceived
|
95 | performance for end-users.
|
96 |
|
97 | Why not just render the one that the current device needs? We can't accurately
|
98 | identify which breakpoint your device needs on the server. We could use a
|
99 | library to sniff the browser user-agent, but those aren't always accurate, and
|
100 | they wouldn't give us all the information we need to know when we are
|
101 | server-rendering. Once client-side JS boots and React attaches, it simply washes
|
102 | over the DOM and removes markup that is unneeded, via a `matchMedia` call.
|
103 |
|
104 | ### SSR Example
|
105 |
|
106 | First, configure `@artsy/fresnel` in a `Media` file that can be shared across
|
107 | the app:
|
108 |
|
109 | ```tsx
|
110 | // Media.tsx
|
111 |
|
112 | import { createMedia } from "@artsy/fresnel"
|
113 |
|
114 | const ExampleAppMedia = createMedia({
|
115 | breakpoints: {
|
116 | sm: 0,
|
117 | md: 768,
|
118 | lg: 1024,
|
119 | xl: 1192,
|
120 | },
|
121 | })
|
122 |
|
123 | // Generate CSS to be injected into the head
|
124 | export const mediaStyle = ExampleAppMedia.createMediaStyle()
|
125 | export const { Media, MediaContextProvider } = ExampleAppMedia
|
126 | ```
|
127 |
|
128 | Create a new `App` file which will be the launching point for our application:
|
129 |
|
130 | ```tsx
|
131 | // App.tsx
|
132 |
|
133 | import React from "react"
|
134 | import { Media, MediaContextProvider } from "./Media"
|
135 |
|
136 | export const App = () => {
|
137 | return (
|
138 | <MediaContextProvider>
|
139 | <Media at="sm">Hello mobile!</Media>
|
140 | <Media greaterThan="sm">Hello desktop!</Media>
|
141 | </MediaContextProvider>
|
142 | )
|
143 | }
|
144 | ```
|
145 |
|
146 | Mount `<App />` on the client:
|
147 |
|
148 | ```tsx
|
149 | // client.tsx
|
150 |
|
151 | import React from "react"
|
152 | import ReactDOM from "react-dom"
|
153 | import { App } from "./App"
|
154 |
|
155 | ReactDOM.render(<App />, document.getElementById("react"))
|
156 | ```
|
157 |
|
158 | Then on the server, setup SSR rendering and pass `mediaStyle` into a `<style>`
|
159 | tag in the header:
|
160 |
|
161 | ```tsx
|
162 | // server.tsx
|
163 |
|
164 | import React from "react"
|
165 | import ReactDOMServer from "react-dom/server"
|
166 | import express from "express"
|
167 |
|
168 | import { App } from "./App"
|
169 | import { mediaStyle } from "./Media"
|
170 |
|
171 | const app = express()
|
172 |
|
173 | app.get("/", (_req, res) => {
|
174 | const html = ReactDOMServer.renderToString(<App />)
|
175 |
|
176 | res.send(`
|
177 | <html>
|
178 | <head>
|
179 | <title>@artsy/fresnel - SSR Example</title>
|
180 |
|
181 | <!–– Inject the generated styles into the page head -->
|
182 | <style type="text/css">${mediaStyle}</style>
|
183 | </head>
|
184 | <body>
|
185 | <div id="react">${html}</div>
|
186 |
|
187 | <script src='/assets/app.js'></script>
|
188 | </body>
|
189 | </html>
|
190 | `)
|
191 | })
|
192 |
|
193 | app.listen(3000, () => {
|
194 | console.warn("\nApp started at http://localhost:3000 \n")
|
195 | })
|
196 | ```
|
197 |
|
198 | And that's it! To test, disable JS and scale your browser window down to a
|
199 | mobile size and reload; it will correctly render the mobile layout without the
|
200 | need to use a user-agent or other server-side "hints".
|
201 |
|
202 | ## Usage with Gatsby or Next
|
203 |
|
204 | `@artsy/fresnel` works great with Gatsby or Next.js's static hybrid approach to
|
205 | rendering. See the examples below for a simple implementation.
|
206 |
|
207 | ## Example Apps
|
208 |
|
209 | There are four examples one can explore in the `/examples` folder:
|
210 |
|
211 | - [Basic](examples/basic)
|
212 | - [Server-side Rendering](examples/ssr-rendering)
|
213 | - [Gatsby](examples/gatsby)
|
214 | - [Next](examples/nextjs)
|
215 | - [Kitchen Sink](examples/kitchen-sink)
|
216 |
|
217 | While the `Basic` and `SSR` examples will get one pretty far, `@artsy/fresnel`
|
218 | can do a lot more . For an exhaustive deep-dive into its features, check out the
|
219 | [Kitchen Sink](examples/kitchen-sink) app.
|
220 |
|
221 | ## Why not conditionally render?
|
222 |
|
223 | Other existing solutions take a conditionally rendered approach, such as
|
224 | [`react-responsive`][react-responsive] or [`react-media`][react-media], so where
|
225 | does this approach differ?
|
226 |
|
227 | Server side rendering!
|
228 |
|
229 | But first, what is conditional rendering?
|
230 |
|
231 | In the React ecosystem a common approach to writing declarative responsive
|
232 | components is to use the browser’s [`matchMedia` api][match-media-api]:
|
233 |
|
234 | ```tsx
|
235 | <Responsive>
|
236 | {({ sm }) => {
|
237 | if (sm) {
|
238 | return <MobileApp />
|
239 | } else {
|
240 | return <DesktopApp />
|
241 | }
|
242 | }}
|
243 | </Responsive>
|
244 | ```
|
245 |
|
246 | On the client, when a given breakpoint is matched React conditionally renders a
|
247 | tree.
|
248 |
|
249 | However, this approach has some limitations for what we wanted to achieve with
|
250 | our server-side rendering setup:
|
251 |
|
252 | - It's impossible to reliably know the user's current breakpoint during the
|
253 | server render phase since that requires a browser.
|
254 |
|
255 | - Setting breakpoint sizes based on user-agent sniffing is prone to errors due
|
256 | the inability to precisely match device capabilities to size. One mobile
|
257 | device might have greater pixel density than another, a mobile device may fit
|
258 | multiple breakpoints when taking device orientation into consideration, and on
|
259 | desktop clients there is no way to know at all. The best devs can do is guess
|
260 | the current breakpoint and populate `<Responsive>` with assumed state.
|
261 |
|
262 | Artsy settled on what we think makes the best trade-offs. We approach this
|
263 | problem in the following way:
|
264 |
|
265 | 1. Render markup for all breakpoints on the server and send it down the wire.
|
266 |
|
267 | 1. The browser receives markup with proper media query styling and will
|
268 | immediately start rendering the expected **visual** result for whatever
|
269 | viewport width the browser is at.
|
270 |
|
271 | 1. When all JS has loaded and React starts the rehydration phase, we query the
|
272 | browser for what breakpoint it’s currently at and then limit the rendered
|
273 | components to the matching media queries. This prevents life-cycle methods
|
274 | from firing in hidden components and unused html being written to the DOM.
|
275 |
|
276 | 1. Additionally, we register event listeners with the browser to notify the
|
277 | `MediaContextProvider` when a different breakpoint is matched and then
|
278 | re-render the tree using the new value for the `onlyMatch` prop.
|
279 |
|
280 | Let’s compare what a component tree using `matchMedia` would look like with our
|
281 | approach:
|
282 |
|
283 | <table>
|
284 | <tr><th>Before</th><th>After</th></tr>
|
285 | <tr><td>
|
286 |
|
287 | ```tsx
|
288 | <Responsive>
|
289 | {({ sm }) => {
|
290 | if (sm) return <SmallArticleItem {...props} />
|
291 | else return <LargeArticleItem {...props} />
|
292 | }}
|
293 | </Responsive>
|
294 | ```
|
295 |
|
296 | </td>
|
297 | <td>
|
298 |
|
299 | ```tsx
|
300 | <>
|
301 | <Media at="sm">
|
302 | <SmallArticleItem {...props} />
|
303 | </Media>
|
304 | <Media greaterThan="sm">
|
305 | <LargeArticleItem {...props} />
|
306 | </Media>
|
307 | </>
|
308 | ```
|
309 |
|
310 | </td></tr>
|
311 | </table>
|
312 |
|
313 | See the [server-side rendering](examples/ssr-rendering) app for a working
|
314 | example.
|
315 |
|
316 | ## API
|
317 |
|
318 | ### createMedia
|
319 |
|
320 | First things first. You’ll need to define the breakpoints and interaction needed
|
321 | for your design to produce the set of media components you can use throughout
|
322 | your application.
|
323 |
|
324 | For example, consider an application that has the following breakpoints:
|
325 |
|
326 | - A viewport width between 0 and 768 (768 not included) points, named `sm`.
|
327 | - A viewport width between 768 and 1024 (1024 not included) points, named `md`.
|
328 | - A viewport width between 1024 and 1192 (1192 not included) points, named `lg`.
|
329 | - A viewport width from 1192 points and above, named `xl`.
|
330 |
|
331 | And the following interactions:
|
332 |
|
333 | - A device that supports hovering a pointer device, named `hover`.
|
334 | - A device that does not support hovering a pointer device, named `notHover`.
|
335 |
|
336 | You would then produce the set of media components like so:
|
337 |
|
338 | ```tsx
|
339 | // Media.tsx
|
340 |
|
341 | const ExampleAppMedia = createMedia({
|
342 | breakpoints: {
|
343 | sm: 0,
|
344 | md: 768,
|
345 | lg: 1024,
|
346 | xl: 1192,
|
347 | },
|
348 | interactions: {
|
349 | hover: "(hover: hover)",
|
350 | notHover: "(hover: none)",
|
351 | landscape: "not all and (orientation: landscape)",
|
352 | portrait: "not all and (orientation: portrait)",
|
353 | },
|
354 | })
|
355 |
|
356 | export const { Media, MediaContextProvider, createMediaStyle } = ExampleAppMedia
|
357 | ```
|
358 |
|
359 | As you can see, breakpoints are defined by their _start_ offset, where the first
|
360 | one is expected to start at 0.
|
361 |
|
362 | ### MediaContextProvider
|
363 |
|
364 | The `MediaContextProvider` component influences how `Media` components will be
|
365 | rendered. Mount it at the root of your component tree:
|
366 |
|
367 | ```tsx
|
368 | import React from "react"
|
369 | import { MediaContextProvider } from "./Media"
|
370 |
|
371 | export const App = () => {
|
372 | return <MediaContextProvider>...</MediaContextProvider>
|
373 | }
|
374 | ```
|
375 |
|
376 | ### Media
|
377 |
|
378 | The `Media` component created for your application has a few mutually exclusive
|
379 | props that make up the API you’ll use to declare your responsive layouts. These
|
380 | props all operate based on the named breakpoints that were provided when you
|
381 | created the media components.
|
382 |
|
383 | ```tsx
|
384 | import React from "react"
|
385 | import { Media } from "./Media"
|
386 |
|
387 | export const HomePage = () => {
|
388 | return (
|
389 | <>
|
390 | <Media at="sm">Hello mobile!</Media>
|
391 | <Media greaterThan="sm">Hello desktop!</Media>
|
392 | </>
|
393 | )
|
394 | }
|
395 | ```
|
396 |
|
397 | The examples given for each prop use breakpoint definitions as defined in the
|
398 | above ‘Setup’ section.
|
399 |
|
400 | If you would like to avoid the underlying div that is generated by `<Media>` and
|
401 | instead use your own element, use the render-props form but be sure to **not**
|
402 | render any children when not necessary:
|
403 |
|
404 | ```tsx
|
405 | export const HomePage = () => {
|
406 | return (
|
407 | <>
|
408 | <Media at="sm">Hello mobile!</Media>
|
409 | <Media greaterThan="sm">
|
410 | {(mediaClassNames, renderChildren) => {
|
411 | return (
|
412 | <MySpecialComponent className={mediaClassNames}>
|
413 | {renderChildren ? "Hello desktop!" : null}
|
414 | </MySpecialComponent>
|
415 | )
|
416 | }}
|
417 | </Media>
|
418 | </>
|
419 | )
|
420 | }
|
421 | ```
|
422 |
|
423 | #### createMediaStyle
|
424 |
|
425 | > Note: This is only used when SSR rendering
|
426 |
|
427 | Besides the `Media` and `MediaContextProvider` components, there's a
|
428 | `createMediaStyle` function that produces the CSS styling for all possible media
|
429 | queries that the `Media` instance can make use of while markup is being passed
|
430 | from the server to the client during hydration. If only a subset of breakpoint
|
431 | keys is used those can be optional specified as a parameter to minimize the
|
432 | output. Be sure to insert this within a `<style>` tag
|
433 | [in your document’s `<head>`](https://github.com/artsy/fresnel/blob/master/examples/ssr-rendering/src/server.tsx#L28).
|
434 |
|
435 | It’s advisable to do this setup in its own module so that it can be easily
|
436 | imported throughout your application:
|
437 |
|
438 | ```tsx
|
439 | import { createMedia } from "@artsy/fresnel"
|
440 |
|
441 | const ExampleAppMedia = createMedia({
|
442 | breakpoints: {
|
443 | sm: 0,
|
444 | md: 768,
|
445 | lg: 1024,
|
446 | xl: 1192,
|
447 | },
|
448 | })
|
449 |
|
450 | // Generate CSS to be injected into the head
|
451 | export const mediaStyle = ExampleAppMedia.createMediaStyle() // optional: .createMediaStyle(['at'])
|
452 | export const { Media, MediaContextProvider } = ExampleAppMedia
|
453 | ```
|
454 |
|
455 | #### onlyMatch
|
456 |
|
457 | Rendering can be constrained to specific breakpoints/interactions by specifying
|
458 | a list of media queries to match. By default _all_ will be rendered.
|
459 |
|
460 | #### disableDynamicMediaQueries
|
461 |
|
462 | By default, when rendered client-side, the browser’s [`matchMedia`
|
463 | api][match-media-api] will be used to _further_ constrain the `onlyMatch` list
|
464 | to only the currently matching media queries. This is done to avoid triggering
|
465 | mount related life-cycle hooks of hidden components.
|
466 |
|
467 | Disabling this behaviour is mostly intended for debugging purposes.
|
468 |
|
469 | #### at
|
470 |
|
471 | Use this to declare that children should only be visible at a specific
|
472 | breakpoint, meaning that the viewport width is greater than or equal to the
|
473 | start offset of the breakpoint, but less than the next breakpoint, if one
|
474 | exists.
|
475 |
|
476 | For example, children of this `Media` declaration will only be visible if the
|
477 | viewport width is between 0 and 768 (768 not included) points:
|
478 |
|
479 | ```tsx
|
480 | <Media at="sm">...</Media>
|
481 | ```
|
482 |
|
483 | The corresponding css rule:
|
484 |
|
485 | ```css
|
486 | @media not all and (min-width: 0px) and (max-width: 767px) {
|
487 | .fresnel-at-sm {
|
488 | display: none !important;
|
489 | }
|
490 | }
|
491 | ```
|
492 |
|
493 | #### lessThan
|
494 |
|
495 | Use this to declare that children should only be visible while the viewport
|
496 | width is less than the start offset of the specified breakpoint.
|
497 |
|
498 | For example, children of this `Media` declaration will only be visible if the
|
499 | viewport width is between 0 and 1024 (1024 not included) points:
|
500 |
|
501 | ```tsx
|
502 | <Media lessThan="lg">...</Media>
|
503 | ```
|
504 |
|
505 | The corresponding css rule:
|
506 |
|
507 | ```css
|
508 | @media not all and (max-width: 1023px) {
|
509 | .fresnel-lessThan-lg {
|
510 | display: none !important;
|
511 | }
|
512 | }
|
513 | ```
|
514 |
|
515 | #### greaterThan
|
516 |
|
517 | Use this to declare that children should only be visible while the viewport
|
518 | width is equal or greater than the start offset of the _next_ breakpoint.
|
519 |
|
520 | For example, children of this `Media` declaration will only be visible if the
|
521 | viewport width is equal or greater than 1024 points:
|
522 |
|
523 | ```tsx
|
524 | <Media greaterThan="md">...</Media>
|
525 | ```
|
526 |
|
527 | The corresponding css rule:
|
528 |
|
529 | ```css
|
530 | @media not all and (min-width: 1024px) {
|
531 | .fresnel-greaterThan-md {
|
532 | display: none !important;
|
533 | }
|
534 | }
|
535 | ```
|
536 |
|
537 | #### greaterThanOrEqual
|
538 |
|
539 | Use this to declare that children should only be visible while the viewport
|
540 | width is equal to the start offset of the specified breakpoint _or_ greater.
|
541 |
|
542 | For example, children of this `Media` declaration will only be visible if the
|
543 | viewport width is 768 points or up:
|
544 |
|
545 | ```tsx
|
546 | <Media greaterThanOrEqual="md">...</Media>
|
547 | ```
|
548 |
|
549 | The corresponding css rule:
|
550 |
|
551 | ```css
|
552 | @media not all and (min-width: 768px) {
|
553 | .fresnel-greaterThanOrEqual-md {
|
554 | display: none !important;
|
555 | }
|
556 | }
|
557 | ```
|
558 |
|
559 | #### between
|
560 |
|
561 | Use this to declare that children should only be visible while the viewport
|
562 | width is equal to the start offset of the first specified breakpoint but less
|
563 | than the start offset of the second specified breakpoint.
|
564 |
|
565 | For example, children of this `Media` declaration will only be visible if the
|
566 | viewport width is between 768 and 1192 (1192 not included) points:
|
567 |
|
568 | ```tsx
|
569 | <Media between={["md", "xl"]}>...</Media>
|
570 | ```
|
571 |
|
572 | The corresponding css rule:
|
573 |
|
574 | ```css
|
575 | @media not all and (min-width: 768px) and (max-width: 1191px) {
|
576 | .fresnel-between-md-xl {
|
577 | display: none !important;
|
578 | }
|
579 | }
|
580 | ```
|
581 |
|
582 | ## Pros vs Cons
|
583 |
|
584 | Pros:
|
585 |
|
586 | - Built on top of simple, proven technology: HTML and CSS media queries.
|
587 | - Users see rendered markup at the correct breakpoint for their device, even
|
588 | before React has been loaded.
|
589 |
|
590 | Cons:
|
591 |
|
592 | - If utilizing SSR rendering features, when the markup is passed down from the
|
593 | server to the client it includes _all_ breakpoints, which increases the page
|
594 | size. (However, once the client mounts, the unused breakpoint markup is
|
595 | cleared from the DOM.)
|
596 | - The current media query is no longer something components can access; it is
|
597 | determined only by the props of the `<Media>` component they find themselves
|
598 | in.
|
599 |
|
600 | That last point presents an interesting problem. How might we represent a
|
601 | component that gets styled differently at different breakpoints? (Let’s imagine
|
602 | a `matchMedia` example.)
|
603 |
|
604 | ```tsx
|
605 | <Sans size={sm ? 2 : 3}>
|
606 | ```
|
607 |
|
608 | ```tsx
|
609 | <>
|
610 | <Media at="sm">
|
611 | {this.getComponent('sm')
|
612 | </Media>
|
613 | <Media greaterThan="sm">
|
614 | {this.getComponent()
|
615 | </Media>
|
616 | </>
|
617 | ```
|
618 |
|
619 | ```tsx
|
620 | getComponent(breakpoint?: string) {
|
621 | const sm = breakpoint === 'sm'
|
622 | return <Sans size={sm ? 2 : 3} />
|
623 | }
|
624 | ```
|
625 |
|
626 | We're still figuring out patterns for this, so please [let us know][new-issue]
|
627 | if you have suggestions.
|
628 |
|
629 | ## Development
|
630 |
|
631 | <details>
|
632 |
|
633 | This project uses [auto-release](https://github.com/intuit/auto-release#readme)
|
634 | to automatically release on every PR. Every PR should have a label that matches
|
635 | one of the following
|
636 |
|
637 | - Version: Trivial
|
638 | - Version: Patch
|
639 | - Version: Minor
|
640 | - Version: Major
|
641 |
|
642 | Major, minor, and patch will cause a new release to be generated. Use major for
|
643 | breaking changes, minor for new non-breaking features, and patch for bug fixes.
|
644 | Trivial will not cause a release and should be used when updating documentation
|
645 | or non-project code.
|
646 |
|
647 | If you don't want to release on a particular PR but the changes aren't trivial
|
648 | then use the `Skip Release` tag along side the appropriate version tag.
|
649 |
|
650 | </details>
|
651 |
|
652 | [ci]: https://circleci.com/gh/artsy/fresnel
|
653 | [ci-icon]: https://circleci.com/gh/artsy/fresnel.svg?style=shield
|
654 | [npm]: https://www.npmjs.com/package/@artsy/fresnel
|
655 | [npm-icon]: https://badge.fury.io/js/%40artsy%2Ffresnel.svg
|
656 | [react-responsive]: https://github.com/contra/react-responsive
|
657 | [react-media]: https://github.com/ReactTraining/react-media
|
658 | [match-media-api]:
|
659 | https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia
|
660 | [new-issue]: https://github.com/artsy/fresnel/issues/new
|
661 | [release-tags]: https://github.com/artsy/fresnel/blob/master/package.json
|