├── .gitignore
├── .netlify
└── state.json
├── README.md
├── demos
├── email-list-android.gif
├── image-grid-android.gif
├── music-drawer-android.gif
├── spring-demo.png
└── tabs.gif
├── package.json
├── public
├── _redirects
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.js
├── Drawer
│ ├── index.js
│ └── styled-components.js
├── EmailList
│ ├── ListItem.js
│ ├── index.js
│ └── styled-components.js
├── Flipper.js
├── GlobalStyle.js
├── MusicDrawer
│ ├── Playlist.js
│ ├── assets
│ │ ├── 99-percent.jpg
│ │ ├── allusionist.jpg
│ │ ├── done-disappeared.jpg
│ │ ├── fast-forward.svg
│ │ ├── like.svg
│ │ ├── memory-palace.jpg
│ │ ├── more-perfect.jpg
│ │ ├── play.svg
│ │ ├── playlist.svg
│ │ ├── radio.svg
│ │ ├── reveal.jpg
│ │ ├── rewind.svg
│ │ ├── search.svg
│ │ ├── snap-judgement.jpg
│ │ ├── song.svg
│ │ ├── the-drop-out.jpg
│ │ ├── the-uncertain-hour.jpg
│ │ ├── this-american-life.jpg
│ │ └── tiny-desk.jpg
│ ├── index.js
│ └── styled-components.js
├── Notification
│ ├── index.js
│ └── styled-components.js
├── PhotoGrid
│ ├── ImageGrid.js
│ ├── drag.js
│ └── index.js
├── SwipeableTabs
│ ├── data.js
│ └── index.js
├── assets
│ ├── coffee-shop.jpg
│ ├── index.js
│ ├── pic-1.jpg
│ ├── pic-10.jpg
│ ├── pic-11.jpg
│ ├── pic-12.jpg
│ ├── pic-13.jpg
│ ├── pic-14.jpg
│ ├── pic-15.jpg
│ ├── pic-16.jpg
│ ├── pic-17.jpg
│ ├── pic-18.jpg
│ ├── pic-19.jpg
│ ├── pic-2.jpg
│ ├── pic-20.jpg
│ ├── pic-21.jpg
│ ├── pic-22.jpg
│ ├── pic-23.jpg
│ ├── pic-24.jpg
│ ├── pic-25.jpg
│ ├── pic-26.jpg
│ ├── pic-27.jpg
│ ├── pic-3.jpg
│ ├── pic-4.jpg
│ ├── pic-5.jpg
│ ├── pic-6.jpg
│ ├── pic-7.jpg
│ ├── pic-8.jpg
│ └── pic-9.jpg
├── index.js
├── logo.svg
├── usePrevious.js
├── useVelocityTrackedSpring.js
├── useWindowSize.js
└── utilities.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.netlify/state.json:
--------------------------------------------------------------------------------
1 | {
2 | "siteId": "240ebf0b-b172-4f05-b716-8b1880d48921"
3 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mobile First Animation in React
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | [View this repo as a live demo in CodeSandbox.](https://codesandbox.io/s/github/aholachek/mobile-first-animation)
20 |
21 | ## React Conf 2019 Talk
22 |
23 | This repo contains the source code of the demos for [this talk about mobile animation](https://www.youtube.com/watch?v=JDDxR1a15Yo&feature=youtu.be&t=10664).
24 |
25 | [The interactive slides for the talk can be found here.](http://mobile-first-animation.netlify.com)
26 |
27 | ## Springs Playground
28 |
29 |
30 |
31 |
32 |
33 | You can play with the [spring demo from the talk here.](https://spring-playground.netlify.com/)
34 |
35 | ## Notes:
36 |
37 | - If you find any bugs or UI inconsistencies, please make an issue!
38 | - These are animation demos and as such they are not production-ready UI code. They are not fully accessible, and don't have desktop variants.
39 | - Adhering to animation best practices can ensure that animations perform acceptably on newer "average", sub-$200 devices. However, there are some devices (older or very inexpensive phones) that will have difficulties achieving minimally acceptable animation performance. Make sure to test your animations on the phones of your target audience before committing to add them.
40 |
--------------------------------------------------------------------------------
/demos/email-list-android.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/demos/email-list-android.gif
--------------------------------------------------------------------------------
/demos/image-grid-android.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/demos/image-grid-android.gif
--------------------------------------------------------------------------------
/demos/music-drawer-android.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/demos/music-drawer-android.gif
--------------------------------------------------------------------------------
/demos/spring-demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/demos/spring-demo.png
--------------------------------------------------------------------------------
/demos/tabs.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/demos/tabs.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mobile-animation-demos",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@reach/tabs": "^0.1.6",
7 | "body-scroll-lock": "^2.6.4",
8 | "react": "^16.10.2",
9 | "react-dom": "^16.10.2",
10 | "react-router": "^5.1.2",
11 | "react-router-dom": "^5.1.2",
12 | "react-scripts": "3.2.0",
13 | "react-spring": "^8.0.27",
14 | "react-use-gesture": "^6.0.3",
15 | "rematrix": "^0.4.1",
16 | "styled-components": "^4.4.0"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "deploy": "yarn run build; netlify deploy --prod --dir=build",
22 | "test": "react-scripts test",
23 | "eject": "react-scripts eject"
24 | },
25 | "eslintConfig": {
26 | "extends": "react-app"
27 | },
28 | "browserslist": {
29 | "production": [
30 | ">0.2%",
31 | "not dead",
32 | "not op_mini all"
33 | ],
34 | "development": [
35 | "last 1 chrome version",
36 | "last 1 firefox version",
37 | "last 1 safari version"
38 | ]
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#fff",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom"
3 | import styled from "styled-components"
4 | import GlobalStyle from "./GlobalStyle"
5 | import SwipeableTabs from "./SwipeableTabs"
6 | import EmailList from "./EmailList"
7 | import MusicDrawer from "./MusicDrawer"
8 | import PhotoGrid from "./PhotoGrid"
9 | import Notification from "./Notification"
10 |
11 | const StyledNav = styled.nav`
12 | padding: 1.5rem;
13 | li {
14 | display: block;
15 | margin-bottom: 1.5rem;
16 | font-size: 1.1rem;
17 | }
18 | h1 {
19 | font-family: "Source Sans Pro", -apple-system, BlinkMacSystemFont,
20 | "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
21 | "Helvetica Neue", sans-serif;
22 | font-weight: bold;
23 | margin-bottom: 1.5rem;
24 | font-size: 1.5rem;
25 | }
26 | `
27 |
28 | const MessageWrapper = styled.div`
29 | display: none;
30 | padding-bottom: 1rem;
31 | padding-left: 1rem;
32 | padding-top: 1rem;
33 | background: blue;
34 | color: white;
35 |
36 | @media (min-width: 768px) {
37 | display: block;
38 | }
39 | `
40 |
41 | const MobileWarning = () => {
42 | return (
43 |
44 | These demos should be viewed on a mobile device, an emulator or the mobile
45 | view in your devtools.
46 |
47 | )
48 | }
49 |
50 | const routes = [
51 | { path: "/email-list", component: EmailList, title: "Email List" },
52 | { path: "/music-drawer", component: MusicDrawer, title: "Music drawer" },
53 | {
54 | path: "/swipeable-tabs",
55 | component: SwipeableTabs,
56 | title: "Swipeable Tabs"
57 | },
58 | { path: "/photo-grid", component: PhotoGrid, title: "Photo Grid" },
59 | { path: "/notification", component: Notification, title: "Notification" }
60 | ]
61 |
62 | function App() {
63 | return (
64 |
65 |
66 |
67 |
68 |
69 | {
73 | return (
74 |
75 | Touch-driven mobile animation examples
76 |
77 | {routes.map(r => (
78 | -
79 | {r.title}
80 |
81 | ))}
82 |
83 |
84 | )
85 | }}
86 | />
87 | {routes.map(r => {
88 | const Component = r.component
89 | return (
90 |
91 |
92 |
93 | )
94 | })}
95 |
96 |
97 |
98 | )
99 | }
100 |
101 | export default App
102 |
--------------------------------------------------------------------------------
/src/Drawer/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import PropTypes from "prop-types"
3 | import { animated } from "react-spring"
4 | import { useDrag } from "react-use-gesture"
5 | import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock"
6 | import useVelocityTrackedSpring from "../useVelocityTrackedSpring"
7 | import {
8 | projection,
9 | rubberBandIfOutOfBounds,
10 | findNearestNumberInArray,
11 | rangeMap
12 | } from "../utilities"
13 | import * as Styles from "./styled-components"
14 |
15 | const threshold = 10
16 |
17 | const BottomDrawer = ({ removeDrawer, children }) => {
18 | const [{ y }, set] = useVelocityTrackedSpring(() => ({ y: 0 }))
19 | const yStops = React.useRef([])
20 | const containerRef = React.useRef(null)
21 |
22 | React.useLayoutEffect(() => {
23 | yStops.current = [0, containerRef.current.clientHeight - 3 * 16]
24 | set({ y: yStops.current[1], immediate: true })
25 | })
26 | React.useEffect(() => {
27 | set({ y: 0, immediate: false })
28 | }, [set])
29 |
30 | const closeDrawer = () => set({ y: yStops.current[1], onRest: removeDrawer })
31 |
32 | const bind = useDrag(
33 | ({ last, movement: [, movementY], vxvy: [, velocityY], memo }) => {
34 | if (!memo) {
35 | const isIntentionalGesture = Math.abs(movementY) > threshold
36 | if (!isIntentionalGesture) return
37 | memo = y.value - movementY
38 | }
39 |
40 | disableBodyScroll(containerRef.current)
41 |
42 | if (last) {
43 | enableBodyScroll(containerRef.current)
44 |
45 | const projectedEndpoint = y.value + projection(velocityY)
46 | const point = findNearestNumberInArray(
47 | projectedEndpoint,
48 | yStops.current
49 | )
50 |
51 | const notificationClosed = point === yStops.current[1]
52 |
53 | return set({
54 | y: notificationClosed ? yStops.current[1] : yStops.current[0],
55 | onRest: notificationClosed ? removeDrawer : () => {},
56 | immediate: false
57 | })
58 | }
59 |
60 | const newY = rubberBandIfOutOfBounds(
61 | yStops.current[0],
62 | yStops.current[1],
63 | memo + movementY,
64 | 0.06
65 | )
66 |
67 | set({
68 | y: newY,
69 | immediate: true
70 | })
71 |
72 | return memo
73 | }
74 | )
75 |
76 | return (
77 | <>
78 | `translateY(${y}px)`)
84 | }}
85 | >
86 |
87 | ×
88 |
89 | {children}
90 |
91 | rangeMap(yStops.current, [1, 0], y))
95 | }}
96 | />
97 | >
98 | )
99 | }
100 |
101 | BottomDrawer.defaultProps = {
102 | removeDrawer: () => {}
103 | }
104 | BottomDrawer.propTypes = {
105 | removeDrawer: PropTypes.func
106 | }
107 |
108 | export default BottomDrawer
109 |
--------------------------------------------------------------------------------
/src/Drawer/styled-components.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components"
2 |
3 | export const Drawer = styled.div`
4 | background-color: white;
5 | padding-top: 1rem;
6 | padding-left: 1rem;
7 | padding-right: 1rem;
8 | padding-bottom: 4rem;
9 | z-index: 10;
10 | position: fixed;
11 | bottom: -4rem;
12 | left: 0;
13 | right: 0;
14 | border-top-left-radius: 8px;
15 | border-top-right-radius: 8px;
16 | will-change: transform;
17 | touch-action: none;
18 | user-select: none;
19 | `
20 |
21 | export const Backdrop = styled.div`
22 | position: fixed;
23 | background-color: hsla(0, 0%, 0%, 0.3);
24 | top: 0;
25 | right: 0;
26 | bottom: 0;
27 | left: 0;
28 | z-index: 9;
29 | `
30 |
31 | export const CloseButton = styled.button`
32 | background: transparent;
33 | border: none;
34 | position: absolute;
35 | top: 1rem;
36 | right: 1rem;
37 | font-size: 1.5rem;
38 | cursor: pointer;
39 | outline: none;
40 | line-height: 1;
41 | padding: 0.1rem 0.2rem;
42 | &:focus {
43 | background-color: hsla(0, 0%, 0%, 0.2);
44 | border-radius: 8px;
45 | }
46 | `
47 |
--------------------------------------------------------------------------------
/src/EmailList/ListItem.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect, useState } from "react"
2 | import {
3 | StyledAction,
4 | StyledListItem,
5 | StyledListItemContainer,
6 | StyledEmail,
7 | StyledAvatar
8 | } from "./styled-components"
9 | import { animated } from "react-spring"
10 | import useVelocityTrackedSpring from "../useVelocityTrackedSpring"
11 | import { useDrag } from "react-use-gesture"
12 | import {
13 | rubberBandIfOutOfBounds,
14 | findNearestNumberInArray,
15 | projection,
16 | range
17 | } from "../utilities"
18 |
19 | const actionWidth = 100
20 | const threshold = 15
21 |
22 | const spring = {
23 | tension: 439,
24 | friction: 40
25 | }
26 |
27 | const actionsOpen = -actionWidth * 2
28 |
29 | const ListItem = ({
30 | avatar,
31 | title,
32 | message,
33 | deleteItem,
34 | id,
35 | isBeingDeleted
36 | }) => {
37 | const itemRef = useRef(null)
38 | const stops = useRef(null)
39 | const [willTransform, setWillTransform] = useState(null)
40 |
41 | useEffect(() => {
42 | stops.current = [0, actionsOpen, -itemRef.current.clientWidth]
43 | }, [])
44 |
45 | const [{ x }, set] = useVelocityTrackedSpring(() => ({
46 | x: 0
47 | }))
48 |
49 | const calculateDeleteButtonTransforms = x => {
50 | if (x < actionsOpen && stops.current) {
51 | const deleteStop = stops.current[stops.current.length - 1]
52 | const dragPercentage = Math.abs(
53 | (x - actionsOpen) / (deleteStop - actionsOpen)
54 | )
55 | const translateX = range(-actionWidth, deleteStop, dragPercentage)
56 | const scaleX = range(1, -deleteStop / actionWidth, dragPercentage)
57 |
58 | return { scaleX, translateX }
59 | }
60 | return { scaleX: 1.001, translateX: x / 2 }
61 | }
62 |
63 | const bind = useDrag(
64 | ({
65 | vxvy: [velocityX],
66 | movement: [movementX, movementY],
67 | delta: [deltaX],
68 | last,
69 | memo,
70 | cancel
71 | }) => {
72 | if (!memo) {
73 | const isIntentionalGesture =
74 | Math.abs(movementX) > threshold &&
75 | Math.abs(movementX) > Math.abs(movementY)
76 |
77 | if (!isIntentionalGesture) {
78 | if (!willTransform) setWillTransform(true)
79 | return
80 | }
81 | memo = x.value - movementX
82 | }
83 |
84 | // hack
85 | const isSwipeNavigation = deltaX < -200 && velocityX > -100
86 | if (isSwipeNavigation) return cancel()
87 |
88 | let newX
89 | let onRest = () => {}
90 | if (last) {
91 | const projectedEndpoint = x.value + projection(velocityX, "fast")
92 | newX = findNearestNumberInArray(projectedEndpoint, stops.current)
93 | if (newX === stops.current[2]) {
94 | onRest = ({ x }) => {
95 | if (x <= stops.current[2]) {
96 | deleteItem(id)
97 | set({
98 | onRest: null
99 | })
100 | }
101 | }
102 | }
103 | } else {
104 | newX = rubberBandIfOutOfBounds(
105 | stops.current[2],
106 | stops.current[0],
107 | memo + movementX
108 | )
109 | }
110 |
111 | set({
112 | x: newX,
113 | immediate: !last,
114 | onRest,
115 | config: {
116 | ...spring,
117 | clamp: last
118 | }
119 | })
120 |
121 | return memo
122 | }
123 | )
124 |
125 | return (
126 |
133 | `translateX(${x}px)`)
137 | }}
138 | >
139 |
140 | {avatar}
141 |
142 |
{title}
143 |
{message}
144 |
145 |
146 |
147 | `translateX(${x}px) scaleY(0.999)`)
153 | }}
154 | >
155 | Archive
156 |
157 | {
162 | const { scaleX, translateX } = calculateDeleteButtonTransforms(x)
163 | return `translateX(${translateX}px) scaleX(${scaleX})`
164 | })
165 | }}
166 | >
167 | {
170 | const { scaleX } = calculateDeleteButtonTransforms(x)
171 | return `scaleX(${1 / scaleX})`
172 | })
173 | }}
174 | >
175 | Delete
176 |
177 |
178 |
179 | )
180 | }
181 |
182 | export default React.memo(ListItem)
183 |
--------------------------------------------------------------------------------
/src/EmailList/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import styled from "styled-components"
3 | import ListItem from "./ListItem"
4 | import { StyledCollapseHandler } from "./styled-components"
5 |
6 | const StyledEmailList = styled.ul``
7 |
8 | const messageIds = [...new Array(80).keys()]
9 |
10 | const emails = [
11 | {
12 | avatar: "A",
13 | title: "Hi from Alex",
14 | message: "Can you pick up some ice cream...",
15 | id: 1
16 | },
17 | {
18 | avatar: "L",
19 | title: "Message from the Library",
20 | message: "Your library books are overdue",
21 | id: 2
22 | },
23 | {
24 | avatar: "K",
25 | title: "Whats up",
26 | message: "Want to grab coffee after work...",
27 | id: 3
28 | },
29 | {
30 | avatar: "M",
31 | title: "Great sale",
32 | message: "Act now to get your hands on...",
33 | id: 4
34 | },
35 | {
36 | avatar: "L",
37 | title: "Message from the Library",
38 | message: "Your library books will soon be...",
39 | id: 5
40 | },
41 | {
42 | avatar: "K",
43 | title: "LOL",
44 | message: "This gif is my life right now...",
45 | id: 6
46 | }
47 | ]
48 |
49 | const exitDuration = 250
50 |
51 | const Demo = () => {
52 | const [emailIds, setEmailIds] = React.useState(messageIds)
53 | const [deletingId, setDeletingId] = React.useState()
54 | const listRef = React.useRef(null)
55 | const collapseHandlerRef = React.useRef(null)
56 |
57 | const deleteItem = React.useCallback(deleteId => {
58 | setDeletingId(deleteId)
59 | const componentsAfter = [
60 | ...listRef.current.querySelectorAll("[data-list-id]")
61 | ].filter(component => {
62 | const id = component.dataset.listId
63 | if (id <= deleteId) return false
64 | return true
65 | })
66 | collapseHandlerRef.current.style.transition = "none"
67 | collapseHandlerRef.current.style.transform = `translateY(71px)`
68 | const fragment = document.createDocumentFragment()
69 | componentsAfter.forEach(c => fragment.appendChild(c))
70 | collapseHandlerRef.current.appendChild(fragment)
71 |
72 | requestAnimationFrame(() => {
73 | collapseHandlerRef.current.style.transition = ""
74 | collapseHandlerRef.current.style.transform = "translateY(-1px)"
75 | setTimeout(() => {
76 | componentsAfter.forEach(c => listRef.current.appendChild(c))
77 | listRef.current.appendChild(collapseHandlerRef.current)
78 |
79 | setEmailIds(prevEmails => prevEmails.filter(id => id !== deleteId))
80 | }, exitDuration)
81 | })
82 | }, [])
83 |
84 | return (
85 |
86 | {emailIds.map(id => {
87 | const isBeingDeleted = id === deletingId
88 | const { avatar, title, message } = emails[id % emails.length]
89 | return (
90 |
99 | )
100 | })}
101 |
105 |
106 | )
107 | }
108 |
109 | export default Demo
110 |
--------------------------------------------------------------------------------
/src/EmailList/styled-components.js:
--------------------------------------------------------------------------------
1 | import styled, { css } from "styled-components"
2 |
3 | export const StyledEmail = styled.div`
4 | padding: 0.75rem;
5 | display: flex;
6 | align-items: center;
7 | h3 {
8 | font-weight: bold;
9 | margin-bottom: 0.3rem;
10 | }
11 | `
12 |
13 | export const StyledAvatar = styled.div`
14 | background-color: #f3f3f3;
15 | height: 2.5rem;
16 | width: 2.5rem;
17 | border-radius: 10rem;
18 | display: flex;
19 | justify-content: center;
20 | align-items: center;
21 | margin-right: 1rem;
22 | `
23 |
24 | export const StyledMoreAction = styled.button``
25 |
26 | export const StyledAction = styled.button`
27 | position: absolute;
28 | top: 0;
29 | bottom: 0;
30 | right: -100px;
31 | border-radius: 0;
32 | width: ${props => props.width}px;
33 | background-color: ${({ archiveAction }) =>
34 | archiveAction ? "#5c6bc0" : "#ef5350"};
35 | color: white;
36 | display: flex;
37 | justify-content: center;
38 | align-items: center;
39 | transform-origin: 0 0;
40 | `
41 | export const StyledListItem = styled.div`
42 | background-color: white;
43 | position: relative;
44 | z-index: 1;
45 | border-bottom: 1px solid #f3f3f3;
46 | h3 {
47 | margin-bottom: 0 !important;
48 | }
49 | touch-action: pan-y;
50 | `
51 |
52 | export const StyledListItemContainer = styled.li`
53 | position: ${props => (props.isBeingDeleted ? "absolute" : "relative")};
54 | width: 100%;
55 | ${props =>
56 | props.willTransform
57 | ? css`
58 | will-change: transform;
59 | > ${StyledListItem} {
60 | will-change: transform;
61 | }
62 | > ${StyledAction} {
63 | will-change: transform;
64 | > div {
65 | will-change: transform;
66 | }
67 | }
68 | `
69 | : ""}
70 | overflow: hidden;
71 | `
72 |
73 | export const StyledCollapseHandler = styled.div`
74 | will-change: transform;
75 | transition: transform ${props => props.exitDuration}ms ease-out;
76 | `
77 |
--------------------------------------------------------------------------------
/src/Flipper.js:
--------------------------------------------------------------------------------
1 | import * as Rematrix from "rematrix"
2 |
3 | // tiny FLIP technique handler that only does 1 animation at a time
4 | class Flipper {
5 | constructor({ ref, onFlip }) {
6 | this.ref = ref
7 | this.onFlip = onFlip
8 | this.positions = {}
9 | }
10 | // mark FLIP-able elements with this data attribute
11 | getEl = id => this.ref.current.querySelector(`[data-flip-key=${id}]`)
12 |
13 | measure(id) {
14 | const el = this.getEl(id)
15 | if (el) return el.getBoundingClientRect()
16 | }
17 |
18 | beforeFlip(id) {
19 | this.positions[id] = this.measure(id)
20 | }
21 |
22 | flip(id, data) {
23 | const el = this.getEl(id)
24 | // cache the current transform for interruptible animations
25 | const startTransform = Rematrix.fromString(el.style.transform)
26 | // we need to figure out what the "real" final state is without any residual transform from an interrupted animation
27 | el.style.transform = ""
28 |
29 | const after = this.measure(id)
30 | const before = this.positions[id]
31 | const scaleX = before.width / after.width
32 | const scaleY = before.height / after.height
33 | const x = before.left - after.left
34 | const y = before.top - after.top
35 |
36 | const transformsArray = [
37 | startTransform,
38 | Rematrix.translateX(x),
39 | Rematrix.translateY(y),
40 | Rematrix.scaleX(scaleX),
41 | Rematrix.scaleY(scaleY)
42 | ]
43 |
44 | const matrix = transformsArray.reduce(Rematrix.multiply)
45 |
46 | const diff = {
47 | x: matrix[12],
48 | y: matrix[13],
49 | scaleX: matrix[0],
50 | scaleY: matrix[5]
51 | }
52 | // immediately apply new styles before the next frame
53 | el.style.transform = `translate(${diff.x}px, ${diff.y}px) scaleX(${diff.scaleX}) scaleY(${diff.scaleY})`
54 |
55 | // let the consumer decide how the actual animation should be done
56 | this.onFlip(id, diff, data)
57 | }
58 | }
59 |
60 | export default Flipper
61 |
--------------------------------------------------------------------------------
/src/GlobalStyle.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from "styled-components"
2 |
3 | export default createGlobalStyle`
4 |
5 | /* http://meyerweb.com/eric/tools/css/reset/
6 | v2.0 | 20110126
7 | License: none (public domain)
8 | */
9 |
10 | html, body, div, span, applet, object, iframe,
11 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
12 | a, abbr, acronym, address, big, cite, code,
13 | del, dfn, em, img, ins, kbd, q, s, samp,
14 | small, strike, strong, sub, sup, tt, var,
15 | b, u, i, center,
16 | dl, dt, dd, ol, ul, li,
17 | fieldset, form, label, legend,
18 | table, caption, tbody, tfoot, thead, tr, th, td,
19 | article, aside, canvas, details, embed,
20 | figure, figcaption, footer, header, hgroup,
21 | menu, nav, output, ruby, section, summary,
22 | time, mark, audio, video {
23 | margin: 0;
24 | padding: 0;
25 | border: 0;
26 | font-size: 100%;
27 | font: inherit;
28 | vertical-align: baseline;
29 | }
30 | /* HTML5 display-role reset for older browsers */
31 | article, aside, details, figcaption, figure,
32 | footer, header, hgroup, menu, nav, section {
33 | display: block;
34 | }
35 | body {
36 | line-height: 1;
37 | }
38 | ol, ul {
39 | list-style: none;
40 | }
41 | blockquote, q {
42 | quotes: none;
43 | }
44 | blockquote:before, blockquote:after,
45 | q:before, q:after {
46 | content: '';
47 | content: none;
48 | }
49 | table {
50 | border-collapse: collapse;
51 | border-spacing: 0;
52 | }
53 |
54 |
55 | @import url("https://fonts.googleapis.com/css?family=Source+Code+Pro|Source+Sans+Pro:400,700|Source+Serif+Pro:700&display=swap");
56 |
57 | html {
58 | box-sizing: border-box;
59 | font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
60 | margin: 0;
61 | padding: 0;
62 | border: 0;
63 | font-size: 100%;
64 | vertical-align: baseline;
65 | overscroll-behavior-y: none;
66 | line-height: 1.4;
67 | font-size: 110%;
68 | color: hsl(0, 0%, 17%);
69 | }
70 |
71 | body {
72 | font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
73 | line-height: 1.4;
74 |
75 | }
76 |
77 | *, *:before, *:after {
78 | box-sizing: inherit;
79 | }
80 |
81 | p {
82 | line-height: 1.4;
83 | }
84 |
85 |
86 | button {
87 | border: none;
88 | appearance: none;
89 | background-color: #f3f3f3;
90 | font-size: 1.1rem;
91 | padding: 0.5rem 1.25rem;
92 | border-radius: 10rem;
93 | outline: none;
94 | cursor: pointer;
95 | }
96 |
97 | b {
98 | font-weight: bold !important;
99 | }
100 |
101 | h1,h2,h3,h4 {
102 | font-family: 'Source Serif Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
103 | }
104 |
105 | code {
106 | font-family: 'Source Code Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
107 | }
108 |
109 |
110 | `
111 |
--------------------------------------------------------------------------------
/src/MusicDrawer/Playlist.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import styled from "styled-components"
3 | import doneDisappeared from "./assets/done-disappeared.jpg"
4 | import morePerfect from "./assets/more-perfect.jpg"
5 | import reveal from "./assets/reveal.jpg"
6 | import dropOut from "./assets/the-drop-out.jpg"
7 | import thisAmericanLife from "./assets/this-american-life.jpg"
8 | import ninetyninePercent from "./assets/99-percent.jpg"
9 | import theUncertainHour from "./assets/the-uncertain-hour.jpg"
10 | import allusionist from "./assets/allusionist.jpg"
11 | import memoryPalace from "./assets/memory-palace.jpg"
12 | import tinyDesk from "./assets/tiny-desk.jpg"
13 |
14 | const podcasts = [
15 | {
16 | img: dropOut,
17 | subtitle: "The Drop Out",
18 | title: "The Downfall"
19 | },
20 | { img: morePerfect, subtitle: "More Perfect", title: "The Heist" },
21 | { img: allusionist, subtitle: "The Allusionist", title: "A Novel Remedy" },
22 | { img: reveal, subtitle: "Reveal", title: "Hate is all around you" },
23 | {
24 | img: doneDisappeared,
25 | subtitle: "Done Disappeared",
26 | title: "Knitting Circle"
27 | },
28 | {
29 | img: thisAmericanLife,
30 | subtitle: "This American Life",
31 | title: "Escape from the Lab"
32 | },
33 | {
34 | img: ninetyninePercent,
35 | subtitle: "99% Invisible",
36 | title: "Invisible Women"
37 | },
38 | {
39 | img: theUncertainHour,
40 | subtitle: "The Uncertain Hour",
41 | title: "George H.W. Bush and his baggie..."
42 | },
43 | {
44 | img: memoryPalace,
45 | subtitle: "The Memory Palace",
46 | title: "Shipwreck Kelly"
47 | },
48 | { img: tinyDesk, subtitle: "Tiny Desk Concerts", title: "Kian Soltani" }
49 | ]
50 | const StyledPlaylist = styled.ul`
51 | margin: 0;
52 | padding: 0;
53 | `
54 | const PlaylistItem = styled.li`
55 | list-style-type: none;
56 | padding-top: 0.75rem;
57 | padding-bottom: 0.75rem;
58 | display: flex;
59 | align-items: center;
60 | position: relative;
61 | -webkit-user-select: none;
62 | &::after {
63 | content: "";
64 | width: calc(100% - 3rem);
65 | position: absolute;
66 | bottom: 0;
67 | right: 0;
68 | height: 1px;
69 | background-color: hsla(0, 0%, 0%, 0.1);
70 | }
71 | `
72 | const Subtitle = styled.div`
73 | font-size: 0.9rem;
74 | `
75 |
76 | const Album = styled.img`
77 | width: 2.5rem;
78 | height: 2.5rem;
79 | background-color: hsla(0, 0%, 0%, 0.5);
80 | border-radius: 3px;
81 | margin-right: 1rem;
82 | `
83 |
84 | const Playlist = () => {
85 | return (
86 |
87 | {podcasts.map(p => {
88 | return (
89 |
90 |
91 |
92 | {p.title}
93 | {p.subtitle}
94 |
95 |
96 | )
97 | })}
98 |
99 | )
100 | }
101 |
102 | export default Playlist
103 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/99-percent.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/99-percent.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/allusionist.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/allusionist.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/done-disappeared.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/done-disappeared.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/fast-forward.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/like.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/memory-palace.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/memory-palace.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/more-perfect.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/more-perfect.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/play.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/playlist.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/radio.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/reveal.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/reveal.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/rewind.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/snap-judgement.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/snap-judgement.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/song.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/the-drop-out.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/the-drop-out.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/the-uncertain-hour.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/the-uncertain-hour.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/this-american-life.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/this-american-life.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/assets/tiny-desk.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/MusicDrawer/assets/tiny-desk.jpg
--------------------------------------------------------------------------------
/src/MusicDrawer/index.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from "react"
2 | import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock"
3 | import { animated } from "react-spring"
4 | import useVelocityTrackedSpring from "../useVelocityTrackedSpring"
5 | import {
6 | Container,
7 | PlaylistDrawer,
8 | NowPlayingDrawer,
9 | NowPlayingImage,
10 | Backdrop,
11 | TabBar,
12 | TabBarItem,
13 | Handle,
14 | ClosedControlsContainer,
15 | OpenControlsContainer,
16 | Title,
17 | Subtitle,
18 | Flex,
19 | StyledClosedTitle
20 | } from "./styled-components"
21 | import { useDrag } from "react-use-gesture"
22 | import {
23 | rubberBandIfOutOfBounds,
24 | findNearestNumberInArray,
25 | projection,
26 | rangeMap
27 | } from "../utilities"
28 | import useWindowSize from "../useWindowSize"
29 |
30 | import Playlist from "./Playlist"
31 |
32 | import { ReactComponent as LikeIcon } from "./assets/like.svg"
33 | import { ReactComponent as PlaylistIcon } from "./assets/playlist.svg"
34 | import { ReactComponent as RadioIcon } from "./assets/radio.svg"
35 | import { ReactComponent as SearchIcon } from "./assets/search.svg"
36 | import { ReactComponent as SongIcon } from "./assets/song.svg"
37 | import { ReactComponent as PlayIcon } from "./assets/play.svg"
38 | import { ReactComponent as RewindIcon } from "./assets/rewind.svg"
39 | import { ReactComponent as FastForwardIcon } from "./assets/fast-forward.svg"
40 | import snapJudgement from "./assets/snap-judgement.jpg"
41 |
42 | const tabIcons = [LikeIcon, PlaylistIcon, RadioIcon, SearchIcon, SongIcon]
43 |
44 | const drawerHeight = 160
45 | const nowPlayingImageDimensions = 70
46 | const drawerPadding = 16
47 |
48 | const ApplePlaylist = () => {
49 | const { width, height } = useWindowSize()
50 | const nowPlayingDrawerRef = useRef(null)
51 |
52 | const stops = [0, -(height - drawerHeight - 40)]
53 |
54 | const spring = {
55 | tension: 247,
56 | friction: 27
57 | }
58 |
59 | const dampedSpring = {
60 | tension: 247,
61 | friction: 33
62 | }
63 |
64 | const [{ y }, set] = useVelocityTrackedSpring(() => ({
65 | y: 0,
66 | config: spring
67 | }))
68 |
69 | const setDrawerOpen = () => {
70 | set({
71 | y: stops[1],
72 | config: dampedSpring,
73 | immediate: false
74 | })
75 | }
76 |
77 | const threshold = 10
78 |
79 | const bind = useDrag(
80 | ({
81 | vxvy: [, velocityY],
82 | movement: [movementX, movementY],
83 | last,
84 | memo,
85 | event
86 | }) => {
87 | event.preventDefault()
88 |
89 | const drawerIsOpen = y.value === stops[1]
90 |
91 | const isClick =
92 | last && Math.abs(movementX) + Math.abs(movementY) <= 3 && !drawerIsOpen
93 |
94 | if (isClick) return setDrawerOpen()
95 |
96 | if (!memo) {
97 | const isIntentionalGesture =
98 | Math.abs(movementY) > threshold &&
99 | Math.abs(movementY) > Math.abs(movementX)
100 |
101 | if (!isIntentionalGesture) return
102 | disableBodyScroll(nowPlayingDrawerRef.current)
103 | memo = y.value - movementY
104 | }
105 |
106 | if (last) {
107 | enableBodyScroll(nowPlayingDrawerRef.current)
108 |
109 | const projectedEndpoint = y.value + projection(velocityY)
110 | const point = findNearestNumberInArray(projectedEndpoint, stops)
111 |
112 | return set({
113 | y: point,
114 | immediate: false,
115 | config: spring
116 | })
117 | }
118 |
119 | const newY = rubberBandIfOutOfBounds(
120 | stops[stops.length - 1],
121 | stops[0],
122 | movementY + memo,
123 | 0.08
124 | )
125 |
126 | set({
127 | y: newY,
128 | immediate: true
129 | })
130 | return memo
131 | },
132 | {
133 | domTarget: nowPlayingDrawerRef,
134 | event: { passive: false }
135 | }
136 | )
137 |
138 | React.useEffect(bind, [bind])
139 |
140 | const getImageTransform = () => {
141 | const newWidth = (2 * width) / 3
142 | const scale = newWidth / nowPlayingImageDimensions / 3
143 | const paddingLeft = (width - newWidth) / 2
144 | const translateX = paddingLeft - drawerPadding
145 | return { scale, translateX }
146 | }
147 |
148 | return (
149 |
150 |
151 |
152 |
153 |
159 | `translate3D(0, ${y}px, 0)`)
167 | }}
168 | >
169 |
175 |
176 | {
182 | const endTransform = getImageTransform()
183 | const translateX = rangeMap(
184 | stops,
185 | [0, endTransform.translateX],
186 | y
187 | )
188 |
189 | const translateY = rangeMap(stops, [0, 30], y)
190 | const scale = rangeMap(stops, [0.333, endTransform.scale], y)
191 |
192 | const scaleX = Math.max(
193 | 0.333,
194 | Math.min(scale, endTransform.scale)
195 | )
196 | return `translate3D(${translateX}px, ${translateY}px, 0) scaleX(${scaleX}) scaleY(${scale})`
197 | })
198 | }}
199 | />
200 |
201 |
207 | Head Games
208 | Snap Judgement
209 |
210 |
211 |
218 |
219 |
220 |
221 |
222 |
228 | Head Games
229 | Snap Judgement
230 |
235 |
236 |
237 | {
242 | const translateY = rangeMap(stops, [0, 60], y)
243 | return `translate3D(0px, ${translateY}px, 0)`
244 | })
245 | }}
246 | >
247 | {tabIcons.map(Icon => (
248 |
249 |
250 |
251 | ))}
252 |
253 |
254 | )
255 | }
256 |
257 | export default ApplePlaylist
258 |
--------------------------------------------------------------------------------
/src/MusicDrawer/styled-components.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components"
2 |
3 | const borderColor = `hsla(0, 0%, 0%, 0.1)`
4 |
5 | export const Title = styled.h3`
6 | max-width: 10rem;
7 | overflow: hidden;
8 | white-space: nowrap;
9 | text-overflow: ellipsis;
10 | font-weight: bold;
11 | user-select: none;
12 | font-family: "Source Sans Pro";
13 | `
14 |
15 | export const Subtitle = styled.h4`
16 | user-select: none;
17 | font-family: "Source Sans Pro";
18 | `
19 |
20 | export const Container = styled.div`
21 | background: hsla(0, 0%, 0%, 1);
22 | height: 100vh;
23 | position: relative;
24 | overflow: hidden;
25 | `
26 |
27 | const drawerMixin = ({ padding }) => `
28 | padding: ${padding}px;
29 | width: 100%;
30 | background-color: white;
31 | `
32 |
33 | export const PlaylistDrawer = styled.div`
34 | ${drawerMixin};
35 | border-top-left-radius: 15px;
36 | border-top-right-radius: 15px;
37 | min-height: calc(100vh + 2rem);
38 | padding-top: 2rem;
39 | position: relative;
40 | top: -2rem;
41 | will-change: transform;
42 | `
43 |
44 | export const Handle = styled.div`
45 | width: 4rem;
46 | height: 0.4rem;
47 | background-color: hsla(0, 0%, 0%, 0.1);
48 | border-radius: 9px;
49 | position: absolute;
50 | top: 1rem;
51 | position: absolute;
52 | left: 50%;
53 | transform: translateX(-50%);
54 | `
55 |
56 | export const NowPlayingDrawer = styled.div`
57 | touch-action: none;
58 | will-change: transform;
59 | position: fixed;
60 | height: ${props => props.height}px;
61 | border-top: 1px solid ${borderColor};
62 | top: ${props => props.windowHeight - 150}px;
63 | min-height: calc(100vh + 500px);
64 | ${drawerMixin};
65 | box-shadow: 0 -3px 10px hsla(0, 0%, 0%, 0.07);
66 |
67 | h1 {
68 | font-size: 1.5rem;
69 | margin-bottom: 1rem;
70 | }
71 | `
72 |
73 | export const ClosedControlsContainer = styled.div`
74 | position: absolute;
75 | right: 0;
76 | top: 1.7rem;
77 | right: 12px;
78 |
79 | svg {
80 | width: 2.25rem;
81 | height: auto;
82 | opacity: 0.6;
83 | }
84 | `
85 |
86 | export const Flex = styled.div`
87 | display: flex;
88 | `
89 |
90 | export const OpenControlsContainer = styled.div`
91 | position: absolute;
92 | left: 0;
93 | top: calc(66vw + 3.75rem);
94 | width: 100vw;
95 |
96 | > div {
97 | display: flex;
98 | justify-content: center;
99 | }
100 | svg {
101 | opacity: 0.7;
102 | width: 3.5rem;
103 | height: 3.5rem;
104 | }
105 | svg:nth-of-type(2) {
106 | width: 5rem;
107 | height: 3.5rem;
108 | }
109 |
110 | > ${Title} {
111 | text-align: center;
112 | position: relative;
113 | font-size: 1.5rem;
114 | margin-top: 1.5rem;
115 | max-width: 100%;
116 | }
117 |
118 | > ${Subtitle} {
119 | text-align: center;
120 | font-size: 1.1rem;
121 | margin-bottom: 1rem;
122 | }
123 | `
124 |
125 | export const NowPlayingImage = styled.img.attrs({
126 | draggable: false
127 | })`
128 | width: ${props => props.dimensions * 3}px;
129 | height: ${props => props.dimensions * 3}px;
130 | border-radius: 3px;
131 | background-color: black;
132 | box-shadow: 0 3px 20px hsla(0, 0%, 0%, 0.3);
133 | transform-origin: 0 0;
134 | display: block;
135 | margin-right: 1rem;
136 | position: relative;
137 | z-index: 1;
138 | transform: scale(0.3333);
139 | position: absolute;
140 | will-change: transform;
141 | user-select: none;
142 | `
143 |
144 | export const Backdrop = styled.div`
145 | position: fixed;
146 | top: 0;
147 | left: 0;
148 | right: 0;
149 | bottom: 0;
150 | background-color: hsla(0, 0%, 0%, 0.4);
151 | pointer-events: none;
152 | `
153 |
154 | export const TabBar = styled.div`
155 | border-top: 1px solid ${borderColor};
156 | position: fixed;
157 | padding: 0.5rem;
158 | bottom: 0;
159 | width: 100%;
160 | display: flex;
161 | justify-content: space-around;
162 | align-items: center;
163 | background-color: white;
164 | `
165 |
166 | export const TabBarItem = styled.div`
167 | opacity: 0.5;
168 | `
169 |
170 | export const StyledClosedTitle = styled.div`
171 | position: relative;
172 | left: 5rem;
173 | top: 0.5rem;
174 | `
175 |
--------------------------------------------------------------------------------
/src/Notification/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react"
2 |
3 | import { animated } from "react-spring"
4 | import { useDrag } from "react-use-gesture"
5 | import useVelocityTrackedSpring from "../useVelocityTrackedSpring"
6 |
7 | import {
8 | StyledNotification,
9 | StyledNotificationContainer,
10 | StyledContainer
11 | } from "./styled-components"
12 | import {
13 | findNearestNumberInArray,
14 | projection,
15 | rubberBandIfOutOfBounds
16 | } from "../utilities"
17 |
18 | const yStops = [0, 120]
19 | const threshold = 10
20 |
21 | const Notification = ({ children, hideNotification }) => {
22 | const [{ y }, set] = useVelocityTrackedSpring(() => ({ y: yStops[1] }))
23 |
24 | useEffect(() => {
25 | set({ y: 0 })
26 | }, [set])
27 |
28 | const bind = useDrag(
29 | ({ last, movement: [, movementY], vxvy: [, velocityY], memo }) => {
30 | if (!memo) {
31 | const isIntentionalGesture = Math.abs(movementY) > threshold
32 | if (!isIntentionalGesture) return
33 | memo = y.value - movementY
34 | }
35 |
36 | if (last) {
37 | const projectedEndpoint = y.value + projection(velocityY)
38 | const point = findNearestNumberInArray(projectedEndpoint, yStops)
39 |
40 | const notificationClosed = point === yStops[1]
41 |
42 | return set({
43 | y: notificationClosed ? yStops[1] : yStops[0],
44 | onRest: notificationClosed ? hideNotification : () => {},
45 | immediate: false
46 | })
47 | }
48 |
49 | const newY = rubberBandIfOutOfBounds(
50 | yStops[0],
51 | yStops[1],
52 | memo + movementY
53 | )
54 |
55 | set({
56 | y: newY,
57 | immediate: true
58 | })
59 |
60 | return memo
61 | }
62 | )
63 |
64 | return (
65 |
66 | `translate3D(-50%, ${y}px, 0)`)
72 | }}
73 | >
74 | {children}
75 |
76 |
77 | )
78 | }
79 |
80 | const NotificationDemo = () => {
81 | const [notificationVisible, setNotificationVisible] = React.useState(false)
82 |
83 | return (
84 |
85 |
95 | {notificationVisible && (
96 | {
99 | setNotificationVisible(false)
100 | }}
101 | >
102 |
103 |
104 | 🐶
105 |
106 |
107 | just saying hi
108 |
109 | )}
110 |
111 | )
112 | }
113 |
114 | export default NotificationDemo
115 |
--------------------------------------------------------------------------------
/src/Notification/styled-components.js:
--------------------------------------------------------------------------------
1 | import styled from "styled-components"
2 |
3 | export const StyledNotification = styled.div`
4 | position: absolute;
5 | bottom: 2rem;
6 | left: 50%;
7 | transform: translateX(-50%);
8 | border-radius: 8px;
9 | padding: 0.5rem 2rem 0.5rem 2rem;
10 | background-color: #171226;
11 | color: white;
12 | user-select: none;
13 | min-width: 70vw;
14 | pointer-events: all;
15 | font-size: 1.2rem;
16 | display: flex;
17 | align-items: center;
18 | touch-action: none;
19 | > div:first-of-type {
20 | font-size: 2rem;
21 | margin-right: 0.5rem;
22 | }
23 | `
24 |
25 | export const StyledNotificationContainer = styled.div`
26 | overflow: hidden;
27 | position: fixed;
28 | top: 0;
29 | left: 0;
30 | right: 0;
31 | bottom: 0;
32 | `
33 |
34 | export const StyledContainer = styled.div`
35 | display: flex;
36 | justify-content: center;
37 | align-items: center;
38 | height: calc(100vh - 4rem);
39 | `
40 |
--------------------------------------------------------------------------------
/src/PhotoGrid/ImageGrid.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react"
2 | import styled, { css } from "styled-components"
3 | import { animated, useSpring, interpolate } from "react-spring"
4 | import useVelocityTrackedSpring from "../useVelocityTrackedSpring"
5 | import { useDrag } from "react-use-gesture"
6 | import { dragSelected, dragUnselected } from "./drag"
7 | import useWindowSize from "../useWindowSize"
8 |
9 | export const defaultSpringSettings = {
10 | y: 0,
11 | x: 0,
12 | scaleX: 1,
13 | scaleY: 1,
14 | config: {
15 | tension: 500,
16 | friction: 50
17 | }
18 | }
19 |
20 | export const bounceConfig = {
21 | tension: 500,
22 | friction: 30
23 | }
24 |
25 | const StyledGrid = styled.div`
26 | display: grid;
27 | grid-gap: 0.5rem;
28 | margin: 0.5rem;
29 | grid-template-columns: repeat(3, 1fr);
30 | `
31 |
32 | const StyledGridItem = styled.div`
33 | transform-origin: 0 0;
34 | position: relative;
35 | touch-action: manipulation;
36 | ${props =>
37 | props.isSelected
38 | ? css`
39 | height: 100vw;
40 | position: fixed;
41 | top: calc(${props.height / 2}px - 50vw);
42 | left: 0;
43 | right: 0;
44 | touch-action: none;
45 | `
46 | : css`
47 | height: calc(33.33vw - 0.666rem);
48 | `}
49 | > img {
50 | width: 100%;
51 | height: 100%;
52 | object-fit: cover;
53 | }
54 | `
55 |
56 | const GridImage = ({
57 | setSelectedImage,
58 | unsetSelectedImage,
59 | img,
60 | id,
61 | setSpring,
62 | isSelected,
63 | setBackgroundSpring,
64 | zIndexQueue,
65 | height,
66 | width
67 | }) => {
68 | const [{ y }, setY] = useVelocityTrackedSpring(() => ({
69 | y: 0
70 | }))
71 |
72 | const [{ x }, setX] = useSpring(() => ({
73 | x: 0
74 | }))
75 |
76 | const [{ scaleX, scaleY }, setScale] = useSpring(() => ({
77 | scaleX: 1,
78 | scaleY: 1
79 | }))
80 |
81 | const containerRef = React.useRef(null)
82 |
83 | const set = args => {
84 | if (args.y !== undefined) setY(args)
85 | if (args.x !== undefined) setX(args)
86 | if (args.scaleX !== undefined) setScale(args)
87 | }
88 |
89 | const dragCallback = isSelected
90 | ? dragSelected({
91 | onImageDismiss: () => unsetSelectedImage(id),
92 | x,
93 | y,
94 | set,
95 | setBackgroundSpring,
96 | width
97 | })
98 | : dragUnselected({
99 | setSelectedImage: () => setSelectedImage(id)
100 | })
101 |
102 | const bind = useDrag(dragCallback)
103 |
104 | useEffect(() => {
105 | setSpring({
106 | id,
107 | springVals: {
108 | x,
109 | y,
110 | scaleX,
111 | scaleY
112 | },
113 | set
114 | })
115 | // eslint-disable-next-line react-hooks/exhaustive-deps
116 | }, [])
117 |
118 | return (
119 |
120 |
{
129 | const animationInProgress = x !== 0 || y !== 0
130 | if (isSelected) return 5
131 | if (zIndexQueue.slice(-1)[0] === id && animationInProgress) return 5
132 | if (zIndexQueue.indexOf(id) > -1 && animationInProgress) return 2
133 | return 1
134 | }),
135 | transform: interpolate(
136 | [x, y, scaleX, scaleY],
137 | (x, y, scaleX, scaleY) => {
138 | return `translate3d(${x}px, ${y}px, 0) scaleX(${scaleX}) scaleY(${scaleY})`
139 | }
140 | )
141 | }}
142 | >
143 |
144 |
145 |
146 | )
147 | }
148 |
149 | const MemoizedGridImage = React.memo(GridImage)
150 |
151 | const ImageGrid = ({ images, selectedImageId, ...rest }) => {
152 | const { height, width } = useWindowSize()
153 | return (
154 |
155 | {images.map(({ id, img }) => {
156 | return (
157 |
166 | )
167 | })}
168 |
169 | )
170 | }
171 |
172 | export default ImageGrid
173 |
--------------------------------------------------------------------------------
/src/PhotoGrid/drag.js:
--------------------------------------------------------------------------------
1 | import {
2 | rubberBandIfOutOfBounds,
3 | findNearestNumberInArray,
4 | projection,
5 | rangeMap,
6 | clampedRangeMap
7 | } from "../utilities"
8 |
9 | const threshold = 10
10 | const maxYTranslate = 150
11 | export const yStops = [0, maxYTranslate]
12 | const xStops = [-20, 20]
13 | const scaleStops = [1, 0.75]
14 |
15 | export const dragUnselected = ({ setSelectedImage }) => ({
16 | last,
17 | movement
18 | }) => {
19 | if (last && Math.abs(movement[0]) + Math.abs(movement[1]) < 2) {
20 | setSelectedImage()
21 | }
22 | }
23 |
24 | export const dragSelected = ({
25 | onImageDismiss,
26 | x,
27 | y,
28 | set,
29 | setBackgroundSpring,
30 | width
31 | }) => ({
32 | vxvy: [, velocityY],
33 | movement: [movementX, movementY],
34 | last,
35 | memo
36 | }) => {
37 | if (!memo) {
38 | const isIntentionalGesture = Math.abs(movementY) > threshold
39 | if (!isIntentionalGesture) return
40 | memo = {
41 | y: y.value - movementY,
42 | x: x.value - movementX
43 | }
44 | }
45 |
46 | if (last) {
47 | const projectedEndpoint = y.value + projection(velocityY, "fast")
48 | const point = findNearestNumberInArray(projectedEndpoint, yStops)
49 |
50 | if (point === yStops[1]) {
51 | return set({
52 | immediate: false,
53 | y: point,
54 | onFrame: () => {
55 | if (Math.abs(y.lastVelocity) < 1000) {
56 | onImageDismiss()
57 | set({
58 | onFrame: null
59 | })
60 | }
61 | }
62 | })
63 | } else {
64 | setBackgroundSpring({
65 | opacity: 1
66 | })
67 | return set({
68 | immediate: false,
69 | y: 0,
70 | x: 0,
71 | scaleY: 1,
72 | scaleX: 1
73 | })
74 | }
75 | }
76 |
77 | const newY = rubberBandIfOutOfBounds(...yStops, movementY + memo.y)
78 | const newX = rubberBandIfOutOfBounds(...xStops, movementX + memo.x)
79 |
80 | // allow for interruption of enter animation
81 | memo.immediate = memo.immediate || Math.abs(newY - y.value) < 1
82 |
83 | const scale = clampedRangeMap(yStops, scaleStops, movementY + memo.y)
84 |
85 | set({
86 | y: newY,
87 | x: newX + ((1 - scale) / 2) * width,
88 | scaleY: scale,
89 | scaleX: scale,
90 | onFrame: null,
91 | immediate: memo.immediate
92 | })
93 |
94 | setBackgroundSpring({
95 | opacity: rangeMap(yStops, [1.5, 0.5], newY)
96 | })
97 |
98 | return memo
99 | }
100 |
--------------------------------------------------------------------------------
/src/PhotoGrid/index.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useLayoutEffect } from "react"
2 | import styled from "styled-components"
3 | import { animated, useSpring } from "react-spring"
4 | import ImageGrid, { defaultSpringSettings, bounceConfig } from "./ImageGrid"
5 | import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock"
6 | import images from "../assets/index"
7 | import usePrevious from "../usePrevious"
8 | import Flipper from "../Flipper"
9 |
10 | const Background = styled.div`
11 | position: absolute;
12 | top: 0;
13 | left: 0;
14 | right: 0;
15 | bottom: 0;
16 | ${props =>
17 | props.backgroundPointerEvents
18 | ? "pointer-events:none;"
19 | : "pointer-events:all;"};
20 | background-color: white;
21 | z-index: 3;
22 | will-change: opacity;
23 | `
24 |
25 | const StyledContainer = styled.div`
26 | position: relative;
27 | `
28 |
29 | const imageData = images
30 | .map((img, i) => ({ img, id: `img-${i}` }))
31 | .reduce((acc, curr) => {
32 | acc[curr.id] = curr
33 | return acc
34 | }, {})
35 |
36 | const imageIds = Object.keys(imageData)
37 |
38 | const DismissFullScreen = () => {
39 | const containerRef = React.useRef(null)
40 | const zIndexQueue = React.useRef([])
41 |
42 | const [selectedImageId, setSelectedImage] = React.useState(null)
43 |
44 | const [backgroundSpring, setBackgroundSpring] = useSpring(() => {
45 | return {
46 | opacity: 0
47 | }
48 | })
49 |
50 | const springsRef = React.useRef({})
51 |
52 | const setSpring = React.useCallback(({ id, springVals, set }) => {
53 | springsRef.current[id] = {
54 | springVals,
55 | set
56 | }
57 | }, [])
58 |
59 | const flipRef = useRef(
60 | new Flipper({
61 | ref: containerRef,
62 | onFlip(id, vals, data = {}) {
63 | const set = springsRef.current[id].set
64 | const el = this.getEl(id)
65 | el.style.zIndex = 5
66 | set({
67 | ...vals,
68 | immediate: true,
69 | onFrame: () => {}
70 | })
71 |
72 | requestAnimationFrame(() => {
73 | setBackgroundSpring({
74 | opacity: data.isLeaving ? 0 : 1
75 | })
76 |
77 | const springSettings = {
78 | ...defaultSpringSettings,
79 | config: data.isLeaving ? bounceConfig : defaultSpringSettings.config
80 | }
81 |
82 | set(
83 | {
84 | ...springSettings,
85 | immediate: false
86 | },
87 | { skipSetVelocity: true }
88 | )
89 | })
90 | }
91 | })
92 | )
93 |
94 | const previousSelectedImageId = usePrevious(selectedImageId)
95 |
96 | useLayoutEffect(() => {
97 | if (
98 | previousSelectedImageId === undefined ||
99 | previousSelectedImageId === selectedImageId
100 | )
101 | return
102 | if (selectedImageId) {
103 | flipRef.current.flip(selectedImageId)
104 | requestAnimationFrame(() => {
105 | zIndexQueue.current.push(selectedImageId)
106 | disableBodyScroll(containerRef.current)
107 | })
108 | } else {
109 | requestAnimationFrame(() => {
110 | enableBodyScroll(containerRef.current)
111 | })
112 | flipRef.current.flip(previousSelectedImageId, {
113 | isLeaving: true
114 | })
115 | }
116 | }, [previousSelectedImageId, selectedImageId])
117 |
118 | const wrappedSetSelectedImage = React.useCallback(id => {
119 | flipRef.current.beforeFlip(id)
120 | disableBodyScroll()
121 | setSelectedImage(id)
122 | }, [])
123 |
124 | const wrappedUnsetSelectedImage = React.useCallback(id => {
125 | flipRef.current.beforeFlip(id)
126 | enableBodyScroll()
127 | setSelectedImage(null)
128 | }, [])
129 |
130 | return (
131 |
132 | imageData[id])}
140 | />
141 |
142 |
147 |
148 | )
149 | }
150 |
151 | export default DismissFullScreen
152 |
--------------------------------------------------------------------------------
/src/SwipeableTabs/data.js:
--------------------------------------------------------------------------------
1 | import images from "../assets"
2 | const tabs = [
3 | {
4 | tab: "Forest",
5 | img: images[5],
6 | title: "Tranquil Forest",
7 | text:
8 | "Numquam omnis commodi quo hic, architecto repellat veniam a tenetur debitis harum totam saepe necessitatibus! Eos dolores ad distinctio temporibus voluptates eaque?"
9 | },
10 | {
11 | tab: "Waterfall",
12 | img: images[0],
13 | title: "Hidden Waterfall",
14 | text:
15 | "Lorem ipsum dolor sit amet consectetur adipisicing elit. Numquam omnis commodi quo hic, architecto repellat veniam a tenetur debitis harum totam saepe necessitatibus! Eos dolores ad distinctio temporibus voluptates eaque?"
16 | },
17 | {
18 | tab: "Beach",
19 | img: images[24],
20 | title: "Vibrant Beach",
21 | text:
22 | "Lorem ipsum dolor sit amet consectetur adipisicing elit. Numquam omnis commodi quo hic, architecto repellat veniam a tenetur debitis harum totam saepe necessitatibus! Eos dolores ad distinctio temporibus voluptates eaque?"
23 | }
24 | ]
25 |
26 | export default tabs
27 |
--------------------------------------------------------------------------------
/src/SwipeableTabs/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react"
2 | import styled from "styled-components"
3 | import useWindowSize from "../useWindowSize"
4 | import { Tabs, TabList, Tab, TabPanels, TabPanel } from "@reach/tabs"
5 | import tabs from "./data"
6 |
7 | const Line = styled.div`
8 | height: 1rem;
9 | margin-bottom: 0.5rem;
10 | background-color: hsla(0, 0%, 0%, 0.09);
11 | width: ${props => props.width || "100%"};
12 | `
13 | const ContentContainer = styled.div`
14 | padding: 1rem;
15 | padding-top: 0;
16 | `
17 | const StyledTabs = styled(Tabs)`
18 | width: 100vw;
19 | overflow-x: hidden;
20 | `
21 | const StyledActiveTabIndicator = styled.div`
22 | background-color: #ff2192;
23 | width: 20%;
24 | height: 0.3rem;
25 | left: 6.5%;
26 | position: relative;
27 | transition: translate 0.15s ease-in;
28 | will-change: transform;
29 | `
30 |
31 | const StyledTabList = styled(TabList)`
32 | display: flex;
33 | margin-top: 1rem;
34 | `
35 |
36 | const StyledTab = styled(Tab)`
37 | background-color: white;
38 | flex: 1;
39 | font-weight: bold;
40 | font-size: 0.95rem;
41 | `
42 | const StyledTabPanels = styled(TabPanels)`
43 | width: 100vw;
44 | scroll-snap-type: x mandatory;
45 | display: flex;
46 | -webkit-overflow-scrolling: touch;
47 | scroll-snap-stop: always;
48 | overflow-x: scroll;
49 | &::-webkit-scrollbar {
50 | display: none;
51 | }
52 | `
53 |
54 | const StyledTabPanel = styled(TabPanel)`
55 | min-width: 100vw;
56 | min-height: 10rem;
57 | scroll-snap-align: start;
58 | scroll-snap-stop: always;
59 | &[hidden] {
60 | display: block !important;
61 | }
62 | &:focus {
63 | outline: none;
64 | }
65 | `
66 |
67 | const StyledContent = styled.div`
68 | h1 {
69 | font-size: 1.5rem;
70 | margin: 1rem 0 1rem 0;
71 | font-weight: bold;
72 | font-family: "Source Sans Pro", -apple-system, system-ui, BlinkMacSystemFont,
73 | "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif;
74 | }
75 | img {
76 | width: 100%;
77 | height: 15rem;
78 | object-fit: cover;
79 | }
80 | `
81 |
82 | const TabComponent = () => {
83 | const tabPanelsRef = React.useRef(null)
84 | const tabPanelsScrollWidth = React.useRef(null)
85 | const tabIndicatorRef = React.useRef(null)
86 | const [tabIndex, setTabIndex] = useState(0)
87 | const { width } = useWindowSize()
88 |
89 | React.useLayoutEffect(() => {
90 | tabPanelsScrollWidth.current = tabPanelsRef.scrollWidth
91 | }, [width])
92 |
93 | const getTab = x => {
94 | return tabs
95 | .map((t, i) => (i / tabs.length - 1) * width)
96 | .findIndex(distance => Math.abs(x) === Math.abs(distance))
97 | }
98 |
99 | const setScrollLeft = x => {
100 | tabPanelsRef.current.scrollLeft = x
101 | onScrollChanged(x)
102 | }
103 |
104 | const onScrollChanged = scroll => {
105 | if (tabIndicatorRef.current) {
106 | const translateX =
107 | (scroll / (tabPanelsRef.current.clientWidth * tabs.length)) * width
108 | tabIndicatorRef.current.style.transform = `translateX(${translateX}px)`
109 | }
110 | const tabIndex = getTab(scroll)
111 | if (tabIndex === -1) return
112 | setTabIndex(tabIndex)
113 | }
114 |
115 | // const getAdjacentTabs = (tab, i) => {
116 | // return Math.abs(tabIndex - i) <= 1
117 | // }
118 |
119 | return (
120 | <>
121 | {
124 | setScrollLeft((index / tabs.length) * width)
125 | tabPanelsRef.current.scrollLeft = index * tabPanelsScrollWidth.current
126 | }}
127 | >
128 |
129 | {tabs.map(({ tab }) => (
130 | {tab}
131 | ))}
132 |
133 |
134 | {
137 | onScrollChanged(e.target.scrollLeft)
138 | }}
139 | >
140 | {tabs.map(({ img, title }) => {
141 | return (
142 |
143 |
144 |
145 |
146 | {title}
147 |
148 | {[...new Array(4).keys()].map(i => {
149 | return
150 | })}
151 |
152 |
153 | {[...new Array(3).keys()].map(i => {
154 | return
155 | })}
156 |
157 |
158 | {[...new Array(2).keys()].map(i => {
159 | return
160 | })}
161 |
162 |
163 |
164 |
165 | )
166 | })}
167 |
168 |
169 | >
170 | )
171 | }
172 |
173 | export default TabComponent
174 |
--------------------------------------------------------------------------------
/src/assets/coffee-shop.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/coffee-shop.jpg
--------------------------------------------------------------------------------
/src/assets/index.js:
--------------------------------------------------------------------------------
1 | import pic1 from "./pic-1.jpg"
2 | import pic2 from "./pic-2.jpg"
3 | import pic3 from "./pic-3.jpg"
4 | import pic4 from "./pic-4.jpg"
5 | import pic5 from "./pic-5.jpg"
6 | import pic6 from "./pic-6.jpg"
7 | import pic7 from "./pic-7.jpg"
8 | import pic8 from "./pic-8.jpg"
9 | import pic9 from "./pic-9.jpg"
10 | import pic10 from "./pic-10.jpg"
11 | import pic11 from "./pic-11.jpg"
12 | import pic12 from "./pic-12.jpg"
13 | import pic13 from "./pic-13.jpg"
14 | import pic14 from "./pic-14.jpg"
15 | import pic15 from "./pic-15.jpg"
16 | import pic16 from "./pic-16.jpg"
17 | import pic17 from "./pic-17.jpg"
18 | import pic18 from "./pic-18.jpg"
19 | import pic19 from "./pic-19.jpg"
20 | import pic20 from "./pic-20.jpg"
21 | import pic21 from "./pic-21.jpg"
22 | import pic22 from "./pic-22.jpg"
23 | import pic23 from "./pic-23.jpg"
24 | import pic24 from "./pic-24.jpg"
25 | import pic25 from "./pic-25.jpg"
26 | import pic26 from "./pic-26.jpg"
27 | import pic27 from "./pic-27.jpg"
28 |
29 | const images = [
30 | pic1,
31 | pic2,
32 | pic3,
33 | pic4,
34 | pic5,
35 | pic6,
36 | pic7,
37 | pic8,
38 | pic9,
39 | pic10,
40 | pic11,
41 | pic12,
42 | pic13,
43 | pic14,
44 | pic15,
45 | pic16,
46 | pic17,
47 | pic18,
48 | pic19,
49 | pic20,
50 | pic21,
51 | pic22,
52 | pic23,
53 | pic24,
54 | pic25,
55 | pic26,
56 | pic27
57 | ]
58 |
59 | export default images
60 |
--------------------------------------------------------------------------------
/src/assets/pic-1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-1.jpg
--------------------------------------------------------------------------------
/src/assets/pic-10.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-10.jpg
--------------------------------------------------------------------------------
/src/assets/pic-11.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-11.jpg
--------------------------------------------------------------------------------
/src/assets/pic-12.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-12.jpg
--------------------------------------------------------------------------------
/src/assets/pic-13.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-13.jpg
--------------------------------------------------------------------------------
/src/assets/pic-14.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-14.jpg
--------------------------------------------------------------------------------
/src/assets/pic-15.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-15.jpg
--------------------------------------------------------------------------------
/src/assets/pic-16.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-16.jpg
--------------------------------------------------------------------------------
/src/assets/pic-17.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-17.jpg
--------------------------------------------------------------------------------
/src/assets/pic-18.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-18.jpg
--------------------------------------------------------------------------------
/src/assets/pic-19.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-19.jpg
--------------------------------------------------------------------------------
/src/assets/pic-2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-2.jpg
--------------------------------------------------------------------------------
/src/assets/pic-20.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-20.jpg
--------------------------------------------------------------------------------
/src/assets/pic-21.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-21.jpg
--------------------------------------------------------------------------------
/src/assets/pic-22.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-22.jpg
--------------------------------------------------------------------------------
/src/assets/pic-23.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-23.jpg
--------------------------------------------------------------------------------
/src/assets/pic-24.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-24.jpg
--------------------------------------------------------------------------------
/src/assets/pic-25.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-25.jpg
--------------------------------------------------------------------------------
/src/assets/pic-26.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-26.jpg
--------------------------------------------------------------------------------
/src/assets/pic-27.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-27.jpg
--------------------------------------------------------------------------------
/src/assets/pic-3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-3.jpg
--------------------------------------------------------------------------------
/src/assets/pic-4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-4.jpg
--------------------------------------------------------------------------------
/src/assets/pic-5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-5.jpg
--------------------------------------------------------------------------------
/src/assets/pic-6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-6.jpg
--------------------------------------------------------------------------------
/src/assets/pic-7.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-7.jpg
--------------------------------------------------------------------------------
/src/assets/pic-8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-8.jpg
--------------------------------------------------------------------------------
/src/assets/pic-9.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aholachek/mobile-first-animation/05b022b50c4e354545c4f708c1e99914b98cf6db/src/assets/pic-9.jpg
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import ReactDOM from "react-dom"
3 | import App from "./App"
4 |
5 | ReactDOM.render(
6 | ,
7 | document.getElementById("root")
8 | )
9 |
10 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/usePrevious.js:
--------------------------------------------------------------------------------
1 | // https://usehooks.com/usePrevious/
2 | import { useEffect, useRef } from "react"
3 |
4 | function usePrevious(value) {
5 | // The ref object is a generic container whose current property is mutable ...
6 | // ... and can hold any value, similar to an instance property on a class
7 | const ref = useRef()
8 |
9 | // Store current value in ref
10 | useEffect(() => {
11 | ref.current = value
12 | }, [value]) // Only re-run if value changes
13 |
14 | // Return previous value (happens before update in useEffect above)
15 | return ref.current
16 | }
17 |
18 | export default usePrevious
19 |
--------------------------------------------------------------------------------
/src/useVelocityTrackedSpring.js:
--------------------------------------------------------------------------------
1 | import { useSpring } from "react-spring"
2 |
3 | /**
4 | * Use this wrapper hook instead of useSpring from react-spring
5 | * to make sure that your spring animations have velocity,
6 | * even when parts of the animation have been delegated to other means of control
7 | * (e.g. gestures)
8 | */
9 |
10 | const getTrackedVar = (_trackedVar, initialConfig) => {
11 | if (_trackedVar) return _trackedVar
12 | const hasX = initialConfig.x !== undefined
13 | const hasY = initialConfig.y !== undefined
14 | if ((hasX && hasY) || (!hasX && !hasY)) {
15 | throw new Error(
16 | "[useVelocityTrackedSpring] can't automatically detect which variable to track, so you need to specify which variable should be tracked in the second argument"
17 | )
18 | }
19 | return hasX ? "x" : "y"
20 | }
21 |
22 | const useVelocityTrackedSpring = (initialConfigFunc, _trackedVar) => {
23 | const initialConfig = initialConfigFunc()
24 | const trackedVar = getTrackedVar(_trackedVar, initialConfig)
25 | const [springVals, set] = useSpring(initialConfigFunc)
26 | const [{ velocityTracker }, setVelocityTracker] = useSpring(() => ({
27 | velocityTracker: initialConfig[trackedVar],
28 | ...initialConfig
29 | }))
30 |
31 | // you can disable the tracking or setting of velocity by providing options in the second argument
32 | const wrappedSet = (data, { skipTrackVelocity, skipSetVelocity } = {}) => {
33 | // update velocity tracker
34 | const velocityTrackerArgs = { config: data.config }
35 | if (data[trackedVar] && !skipTrackVelocity)
36 | velocityTrackerArgs.velocityTracker = data[trackedVar]
37 | setVelocityTracker(velocityTrackerArgs)
38 |
39 | // update actual spring
40 | if (data.immediate) return set(data)
41 | set({
42 | ...data,
43 | config: {
44 | ...data.config,
45 | velocity: !skipSetVelocity && velocityTracker.lastVelocity
46 | }
47 | })
48 | }
49 | return [springVals, wrappedSet]
50 | }
51 |
52 | export default useVelocityTrackedSpring
53 |
--------------------------------------------------------------------------------
/src/useWindowSize.js:
--------------------------------------------------------------------------------
1 | // https://usehooks.com/useWindowSize/
2 | import { useState, useEffect } from "react"
3 |
4 | // Hook
5 | function useWindowSize() {
6 | const isClient = typeof window === "object"
7 |
8 | // eslint-disable-next-line
9 | function getSize() {
10 | return {
11 | width: isClient ? window.innerWidth : undefined,
12 | height: isClient ? window.innerHeight : undefined
13 | }
14 | }
15 |
16 | const [windowSize, setWindowSize] = useState(getSize)
17 |
18 | useEffect(() => {
19 | if (!isClient) {
20 | return false
21 | }
22 |
23 | function handleResize() {
24 | setWindowSize(getSize())
25 | }
26 |
27 | window.addEventListener("resize", handleResize)
28 | return () => window.removeEventListener("resize", handleResize)
29 | }, [getSize, isClient]) // Empty array ensures that effect is only run on mount and unmount
30 |
31 | return windowSize
32 | }
33 |
34 | export default useWindowSize
35 |
--------------------------------------------------------------------------------
/src/utilities.js:
--------------------------------------------------------------------------------
1 | // percent should be between 0 and 1
2 | export const range = (start, end, percent) => (end - start) * percent + start
3 |
4 | export const clamp = (min, max, val) => Math.max(Math.min(val, max), min)
5 |
6 | // take a number ("val") in between the two numbers in arr1, and map it to a number in between the two numbers in arr2
7 | export const rangeMap = (arr1, arr2, val) => {
8 | const percent = (val - arr1[0]) / (arr1[1] - arr1[0])
9 | return range(arr2[0], arr2[1], percent)
10 | }
11 |
12 | // rangeMap with a guarantee that the returned number will be inside the bounds of arr2
13 | export const clampedRangeMap = (arr1, arr2, val) => {
14 | const min = arr2[0] < arr2[1] ? arr2[0] : arr2[1]
15 | const max = min === arr2[0] ? arr2[1] : arr2[0]
16 | return clamp(min, max, rangeMap(arr1, arr2, val))
17 | }
18 |
19 | // https://twitter.com/chpwn/status/285540192096497664
20 | // iOS constant = 0.55
21 | export const rubberBand = (distance, dimension, constant = 0.15) => {
22 | return (distance * dimension * constant) / (dimension + constant * distance)
23 | }
24 |
25 | // https://medium.com/@nathangitter/building-fluid-interfaces-ios-swift-9732bb934bf5
26 | export const rubberband2 = (offset, constant = 0.7) =>
27 | Math.pow(offset, constant)
28 |
29 | export const rubberBandIfOutOfBounds = (min, max, delta, constant) => {
30 | if (delta < min) {
31 | return -rubberBand(min - delta, max - min, constant) + min
32 | }
33 | if (delta > max) {
34 | return rubberBand(delta - max, max - min, constant) + max
35 | }
36 | return delta
37 | }
38 |
39 | export const decelerationRates = {
40 | fast: 0.99,
41 | normal: 0.998
42 | }
43 |
44 | // https://medium.com/@nathangitter/building-fluid-interfaces-ios-swift-9732bb934bf5
45 | // note: velocity in UIkit is points per second, but react use gesture gives px per millisecond,
46 | // so we can simplify somewhat
47 | export const projection = (initialVelocity, rateName = "normal") => {
48 | const decelerationRate = decelerationRates[rateName] || rateName
49 | return (initialVelocity * decelerationRate) / (1 - decelerationRate)
50 | }
51 |
52 | export const findNearestNumberInArray = (n, arr) => {
53 | const sortedArr = [...arr].sort((a, b) => a - b)
54 | if (n <= sortedArr[0]) return sortedArr[0]
55 | if (n >= sortedArr[arr.length - 1]) return sortedArr[arr.length - 1]
56 |
57 | for (let i = 1; i < sortedArr.length; i++) {
58 | const prev = sortedArr[i - 1]
59 | const current = sortedArr[i]
60 | if (current === n) return current
61 | if (current > n && prev < n) {
62 | return current - n < n - prev ? current : prev
63 | }
64 | }
65 | return false
66 | }
67 |
--------------------------------------------------------------------------------