├── .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 | swipeable tabs 5 | 6 | 7 | an animated drawer inspired by the Apple Music app 8 | 9 | 10 | animated grid of images 11 | 12 | 13 | dismissable email list 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 |
231 | 232 | 233 | 234 |
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 | landscape 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 | landscape 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 | --------------------------------------------------------------------------------