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: "(orientation: landscape)",
|
352 | portrait: "(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:
|
402 |
|
403 | ```tsx
|
404 | export const HomePage = () => {
|
405 | return (
|
406 | <>
|
407 | <Media at="sm">Hello mobile!</Media>
|
408 | <Media greaterThan="xs">
|
409 | {(mediaClassNames) => {
|
410 | return (
|
411 | <MySpecialComponent className={mediaClassNames}>
|
412 | Hello desktop!
|
413 | </MySpecialComponent>
|
414 | )
|
415 | }}
|
416 | </Media>
|
417 | </>
|
418 | )
|
419 | }
|
420 | ```
|
421 |
|
422 | #### createMediaStyle
|
423 |
|
424 | > Note: This is only used when SSR rendering
|
425 |
|
426 | Besides the `Media` and `MediaContextProvider` components, there's a
|
427 | `createMediaStyle` function that produces the CSS styling for all possible media
|
428 | queries that the `Media` instance can make use of while markup is being passed
|
429 | from the server to the client during hydration. Be sure to insert this within a
|
430 | `<style>` tag
|
431 | [in your document’s `<head>`](https://github.com/artsy/fresnel/blob/master/examples/ssr-rendering/src/server.tsx#L28).
|
432 |
|
433 | It’s advisable to do this setup in its own module so that it can be easily
|
434 | imported throughout your application:
|
435 |
|
436 | ```tsx
|
437 | import { createMedia } from "@artsy/fresnel"
|
438 |
|
439 | const ExampleAppMedia = createMedia({
|
440 | breakpoints: {
|
441 | sm: 0,
|
442 | md: 768,
|
443 | lg: 1024,
|
444 | xl: 1192,
|
445 | },
|
446 | })
|
447 |
|
448 | // Generate CSS to be injected into the head
|
449 | export const mediaStyle = ExampleAppMedia.createMediaStyle()
|
450 | export const { Media, MediaContextProvider } = ExampleAppMedia
|
451 | ```
|
452 |
|
453 | #### onlyMatch
|
454 |
|
455 | Rendering can be constrained to specific breakpoints/interactions by specifying
|
456 | a list of media queries to match. By default _all_ will be rendered.
|
457 |
|
458 | #### disableDynamicMediaQueries
|
459 |
|
460 | By default, when rendered client-side, the browser’s [`matchMedia`
|
461 | api][match-media-api] will be used to _further_ constrain the `onlyMatch` list
|
462 | to only the currently matching media queries. This is done to avoid triggering
|
463 | mount related life-cycle hooks of hidden components.
|
464 |
|
465 | Disabling this behaviour is mostly intended for debugging purposes.
|
466 |
|
467 | #### at
|
468 |
|
469 | Use this to declare that children should only be visible at a specific
|
470 | breakpoint, meaning that the viewport width is greater than or equal to the
|
471 | start offset of the breakpoint, but less than the next breakpoint, if one
|
472 | exists.
|
473 |
|
474 | For example, children of this `Media` declaration will only be visible if the
|
475 | viewport width is between 0 and 768 (768 not included) points:
|
476 |
|
477 | ```tsx
|
478 | <Media at="sm">...</Media>
|
479 | ```
|
480 |
|
481 | The corresponding css rule:
|
482 |
|
483 | ```css
|
484 | @media not all and (min-width: 0px) and (max-width: 767px) {
|
485 | .fresnel-at-sm {
|
486 | display: none !important;
|
487 | }
|
488 | }
|
489 | ```
|
490 |
|
491 | #### lessThan
|
492 |
|
493 | Use this to declare that children should only be visible while the viewport
|
494 | width is less than the start offset of the specified breakpoint.
|
495 |
|
496 | For example, children of this `Media` declaration will only be visible if the
|
497 | viewport width is between 0 and 1024 (1024 not included) points:
|
498 |
|
499 | ```tsx
|
500 | <Media lessThan="lg">...</Media>
|
501 | ```
|
502 |
|
503 | The corresponding css rule:
|
504 |
|
505 | ```css
|
506 | @media not all and (max-width: 1023px) {
|
507 | .fresnel-lessThan-lg {
|
508 | display: none !important;
|
509 | }
|
510 | }
|
511 | ```
|
512 |
|
513 | #### greaterThan
|
514 |
|
515 | Use this to declare that children should only be visible while the viewport
|
516 | width is equal or greater than the start offset of the _next_ breakpoint.
|
517 |
|
518 | For example, children of this `Media` declaration will only be visible if the
|
519 | viewport width is equal or greater than 1024 points:
|
520 |
|
521 | ```tsx
|
522 | <Media greaterThan="md">...</Media>
|
523 | ```
|
524 |
|
525 | The corresponding css rule:
|
526 |
|
527 | ```css
|
528 | @media not all and (min-width: 1024px) {
|
529 | .fresnel-greaterThan-md {
|
530 | display: none !important;
|
531 | }
|
532 | }
|
533 | ```
|
534 |
|
535 | #### greaterThanOrEqual
|
536 |
|
537 | Use this to declare that children should only be visible while the viewport
|
538 | width is equal to the start offset of the specified breakpoint _or_ greater.
|
539 |
|
540 | For example, children of this `Media` declaration will only be visible if the
|
541 | viewport width is 768 points or up:
|
542 |
|
543 | ```tsx
|
544 | <Media greaterThanOrEqual="md">...</Media>
|
545 | ```
|
546 |
|
547 | The corresponding css rule:
|
548 |
|
549 | ```css
|
550 | @media not all and (min-width: 768px) {
|
551 | .fresnel-greaterThanOrEqual-md {
|
552 | display: none !important;
|
553 | }
|
554 | }
|
555 | ```
|
556 |
|
557 | #### between
|
558 |
|
559 | Use this to declare that children should only be visible while the viewport
|
560 | width is equal to the start offset of the first specified breakpoint but less
|
561 | than the start offset of the second specified breakpoint.
|
562 |
|
563 | For example, children of this `Media` declaration will only be visible if the
|
564 | viewport width is between 768 and 1192 (1192 not included) points:
|
565 |
|
566 | ```tsx
|
567 | <Media between={["md", "xl"]}>...</Media>
|
568 | ```
|
569 |
|
570 | The corresponding css rule:
|
571 |
|
572 | ```css
|
573 | @media not all and (min-width: 768px) and (max-width: 1191px) {
|
574 | .fresnel-between-md-xl {
|
575 | display: none !important;
|
576 | }
|
577 | }
|
578 | ```
|
579 |
|
580 | ## Pros vs Cons
|
581 |
|
582 | Pros:
|
583 |
|
584 | - Built on top of simple, proven technology: HTML and CSS media queries.
|
585 | - Users see rendered markup at the correct breakpoint for their device, even
|
586 | before React has been loaded.
|
587 |
|
588 | Cons:
|
589 |
|
590 | - If utilizing SSR rendering features, when the markup is passed down from the
|
591 | server to the client it includes _all_ breakpoints, which increases the page
|
592 | size. (However, once the client mounts, the unused breakpoint markup is
|
593 | cleared from the DOM.)
|
594 | - The current media query is no longer something components can access; it is
|
595 | determined only by the props of the `<Media>` component they find themselves
|
596 | in.
|
597 |
|
598 | That last point presents an interesting problem. How might we represent a
|
599 | component that gets styled differently at different breakpoints? (Let’s imagine
|
600 | a `matchMedia` example.)
|
601 |
|
602 | ```tsx
|
603 | <Sans size={sm ? 2 : 3}>
|
604 | ```
|
605 |
|
606 | ```tsx
|
607 | <>
|
608 | <Media at="sm">
|
609 | {this.getComponent('sm')
|
610 | </Media>
|
611 | <Media greaterThan="sm">
|
612 | {this.getComponent()
|
613 | </Media>
|
614 | </>
|
615 | ```
|
616 |
|
617 | ```tsx
|
618 | getComponent(breakpoint?: string) {
|
619 | const sm = breakpoint === 'sm'
|
620 | return <Sans size={sm ? 2 : 3} />
|
621 | }
|
622 | ```
|
623 |
|
624 | We're still figuring out patterns for this, so please [let us know][new-issue]
|
625 | if you have suggestions.
|
626 |
|
627 | ## Development
|
628 |
|
629 | <details>
|
630 |
|
631 | This project uses [auto-release](https://github.com/intuit/auto-release#readme)
|
632 | to automatically release on every PR. Every PR should have a label that matches
|
633 | one of the following
|
634 |
|
635 | - Version: Trivial
|
636 | - Version: Patch
|
637 | - Version: Minor
|
638 | - Version: Major
|
639 |
|
640 | Major, minor, and patch will cause a new release to be generated. Use major for
|
641 | breaking changes, minor for new non-breaking features, and patch for bug fixes.
|
642 | Trivial will not cause a release and should be used when updating documentation
|
643 | or non-project code.
|
644 |
|
645 | If you don't want to release on a particular PR but the changes aren't trivial
|
646 | then use the `Skip Release` tag along side the appropriate version tag.
|
647 |
|
648 | </details>
|
649 |
|
650 | [ci]: https://circleci.com/gh/artsy/fresnel
|
651 | [ci-icon]: https://circleci.com/gh/artsy/fresnel.svg?style=shield
|
652 | [npm]: https://www.npmjs.com/package/@artsy/fresnel
|
653 | [npm-icon]: https://badge.fury.io/js/%40artsy%2Ffresnel.svg
|
654 | [react-responsive]: https://github.com/contra/react-responsive
|
655 | [react-media]: https://github.com/ReactTraining/react-media
|
656 | [match-media-api]:
|
657 | https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia
|
658 | [new-issue]: https://github.com/artsy/fresnel/issues/new
|
659 | [release-tags]: https://github.com/artsy/fresnel/blob/master/package.json
|