├── .babelrc
├── .env
├── .eslintrc
├── .gitignore
├── .prettierrc
├── .storybook
├── StoryContainer.css
├── StoryContainer.js
├── main.js
├── manager-head.html
├── manager.js
├── preview.css
└── preview.js
├── .stylelintrc
├── LICENSE
├── README.md
├── craco.config.js
├── design
├── Google Pixel 6.glb
├── Macbook Pro.gltf
├── iMac 2021.glb
├── iMac Pro.glb
├── iPad Air 3.glb
├── iPhone 11.glb
├── iPhone 12.glb
├── imac-2021.jpg
├── imac-pro.jpg
├── ipad-air.jpg
├── iphone-11.jpg
├── iphone-12.jpg
├── macbook-pro.jpg
└── pixel-6.jpg
├── jsconfig.json
├── manifest.json
├── package.json
├── postcss.config.js
├── public
├── favicon.png
├── humans.txt
├── icon-256.png
├── icon-512.png
├── icon.svg
├── index.html
├── manifest.json
├── robots.txt
├── sitemap.xml
└── social-image.png
├── src
├── app
│ ├── index.css
│ ├── index.js
│ ├── index.test.js
│ └── reset.css
├── assets
│ ├── fonts
│ │ ├── inter-bold.woff2
│ │ ├── inter-medium.woff2
│ │ └── inter-regular.woff2
│ ├── imac-2021-front-left.png
│ ├── imac-2021-front-right.png
│ ├── imac-2021-front.png
│ ├── imac-2021-tilted-left.png
│ ├── imac-2021-tilted-right.png
│ ├── imac-2021.glb
│ ├── imac-2021.jpg
│ ├── imac-pro-front-left.png
│ ├── imac-pro-front-right.png
│ ├── imac-pro-front.png
│ ├── imac-pro-tilted-left.png
│ ├── imac-pro-tilted-right.png
│ ├── imac-pro.glb
│ ├── imac-pro.jpg
│ ├── ipad-air-front-left.png
│ ├── ipad-air-front-right.png
│ ├── ipad-air-front.png
│ ├── ipad-air-tilted-left.png
│ ├── ipad-air-tilted-right.png
│ ├── ipad-air.glb
│ ├── ipad-air.jpg
│ ├── iphone-11-front-left.png
│ ├── iphone-11-front-right.png
│ ├── iphone-11-front.png
│ ├── iphone-11-tilted-left.png
│ ├── iphone-11-tilted-right.png
│ ├── iphone-11.glb
│ ├── iphone-11.jpg
│ ├── iphone-12-front-left.png
│ ├── iphone-12-front-right.png
│ ├── iphone-12-front.png
│ ├── iphone-12-tilted-left.png
│ ├── iphone-12-tilted-right.png
│ ├── iphone-12.glb
│ ├── iphone-12.jpg
│ ├── macbook-pro-front-left.png
│ ├── macbook-pro-front-right.png
│ ├── macbook-pro-front.png
│ ├── macbook-pro-tilted-left.png
│ ├── macbook-pro-tilted-right.png
│ ├── macbook-pro.glb
│ └── macbook-pro.jpg
├── components
│ ├── Button
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── Dropdown
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── Heading
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── Icon
│ │ ├── icons.js
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── Input
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── Link
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── Option
│ │ ├── index.css
│ │ └── index.js
│ ├── Scene
│ │ ├── Canvas.css
│ │ ├── Canvas.js
│ │ ├── Controls.js
│ │ ├── Model.js
│ │ ├── deviceModels.js
│ │ └── index.js
│ ├── Spinner
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── Text
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── ThemeProvider
│ │ ├── index.js
│ │ ├── theme.js
│ │ └── useTheme.js
│ ├── Tooltip
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.stories.js
│ └── VisuallyHidden
│ │ ├── index.css
│ │ └── index.js
├── data
│ ├── colors.js
│ ├── export.js
│ └── presets.js
├── hooks
│ ├── index.js
│ ├── useFormInput.js
│ ├── useId.js
│ ├── useInViewport.js
│ ├── useInterval.js
│ ├── useLocalStorage.js
│ ├── useParallax.js
│ ├── usePrefersColorScheme.js
│ ├── usePrefersReducedMotion.js
│ ├── usePrevious.js
│ ├── useRouteTransition.js
│ ├── useScrollRestore.js
│ └── useWindowSize.js
├── index.js
├── pages
│ ├── 404.js
│ ├── Home.js
│ ├── Intro.css
│ ├── Intro.js
│ └── socials.js
├── plugin
│ ├── figma.js
│ ├── index.css
│ ├── index.js
│ └── reducer.js
└── utils
│ ├── focus.js
│ ├── image.js
│ ├── model.js
│ ├── offset.js
│ ├── prerender.js
│ ├── style.js
│ └── transition.js
├── vercel.json
├── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "babel-preset-react-app",
5 | {
6 | "runtime": "automatic"
7 | }
8 | ]
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | SKIP_PREFLIGHT_CHECK=true
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "react-app",
3 | "plugins": ["react-hooks"],
4 | "rules": {
5 | "import/no-anonymous-default-export": 0,
6 | "react-hooks/exhaustive-deps": "warn",
7 | "react-hooks/rules-of-hooks": "error",
8 | "semi": "error",
9 | "no-restricted-globals": 0
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 |
3 | logs
4 | _.log
5 | npm-debug.log_
6 | yarn-debug.log*
7 | yarn-error.log*
8 |
9 | # Runtime data
10 |
11 | pids
12 | _.pid
13 | _.seed
14 | \*.pid.lock
15 |
16 | # Directory for instrumented libs generated by jscoverage/JSCover
17 |
18 | lib-cov
19 |
20 | # Coverage directory used by tools like istanbul
21 |
22 | coverage
23 |
24 | # nyc test coverage
25 |
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
29 |
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 |
34 | bower_components
35 |
36 | # node-waf configuration
37 |
38 | .lock-wscript
39 |
40 | # Compiled binary addons (http://nodejs.org/api/addons.html)
41 |
42 | build/Release
43 |
44 | # Dependency directories
45 |
46 | node_modules/
47 | jspm_packages/
48 |
49 | # Typescript v1 declaration files
50 |
51 | typings/
52 |
53 | # Optional npm cache directory
54 |
55 | .npm
56 |
57 | # Optional eslint cache
58 |
59 | .eslintcache
60 |
61 | # Optional REPL history
62 |
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 |
67 | \*.tgz
68 |
69 | # Yarn Integrity file
70 |
71 | .yarn-integrity
72 |
73 | build/
74 | build-storybook/
75 | build-plugin/
76 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "printWidth": 90,
4 | "trailingComma": "es5",
5 | "tabWidth": 2,
6 | "semi": true,
7 | "singleQuote": true,
8 | "endOfLine": "auto"
9 | }
10 |
--------------------------------------------------------------------------------
/.storybook/StoryContainer.css:
--------------------------------------------------------------------------------
1 | .story-container {
2 | width: 100vw;
3 | height: 100vh;
4 | display: flex;
5 | align-items: flex-start;
6 | justify-items: flex-start;
7 | }
8 |
--------------------------------------------------------------------------------
/.storybook/StoryContainer.js:
--------------------------------------------------------------------------------
1 | import './StoryContainer.css';
2 |
3 | export const StoryContainer = ({
4 | padding = 32,
5 | stretch,
6 | gutter = 32,
7 | vertical,
8 | children,
9 | }) => (
10 |
20 | {children}
21 |
22 | );
23 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | addons: [
3 | '@storybook/addon-actions',
4 | '@storybook/addon-controls',
5 | '@storybook/addon-a11y',
6 | '@storybook/addon-toolbars',
7 | 'storybook-preset-craco',
8 | ],
9 | stories: ['../src/**/*.stories.js'],
10 | };
11 |
--------------------------------------------------------------------------------
/.storybook/manager-head.html:
--------------------------------------------------------------------------------
1 |
15 |
16 |
21 |
22 | Device Models | Storybook
23 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
54 |
55 |
56 |
57 |
58 |
59 |
63 |
64 |
--------------------------------------------------------------------------------
/.storybook/manager.js:
--------------------------------------------------------------------------------
1 | import { themes } from '@storybook/theming';
2 | import { addons } from '@storybook/addons';
3 |
4 | addons.setConfig({
5 | theme: {
6 | ...themes.light,
7 | brandImage: 'https://devicemodels.com/icon.svg',
8 | brandTitle: 'Device Models Components',
9 | brandUrl: 'https://devicemodels.com',
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/.storybook/preview.css:
--------------------------------------------------------------------------------
1 | .story-root {
2 | position: absolute;
3 | top: 0;
4 | right: 0;
5 | bottom: 0;
6 | left: 0;
7 | }
8 |
--------------------------------------------------------------------------------
/.storybook/preview.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import ThemeProvider from '../src/components/ThemeProvider';
3 | import '../src/app/reset.css';
4 | import '../src/app/index.css';
5 | import './preview.css';
6 |
7 | export const decorators = [
8 | (Story, context) => {
9 | const theme = context.globals.theme;
10 |
11 | useEffect(() => {
12 | document.body.setAttribute('class', theme);
13 | }, [theme]);
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | );
22 | },
23 | ];
24 |
25 | export const globalTypes = {
26 | theme: {
27 | name: 'Theme',
28 | description: 'Global theme for components',
29 | defaultValue: 'light',
30 | toolbar: {
31 | icon: 'circlehollow',
32 | items: ['light', 'dark'],
33 | },
34 | },
35 | };
36 |
37 | export const parameters = {
38 | layout: 'fullscreen',
39 | controls: { hideNoControlsWarning: true },
40 | };
41 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "stylelint-config-recommended"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Cody Bennett
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Device Models
2 |
3 | A Figma plugin to create mockups with 3D device models.
4 |
5 | [](https://devicemodels.com)
6 |
7 | Create mockups with 3D device models. Customize the color, camera angle, and device model for your mockups. Includes models for the iPhone, Macbook Pro, iMac, and iPad with more models on the way for other devices.
8 |
9 | How to use:
10 |
11 | 1. Run Device Models from the plugin menu
12 |
13 | 2. Choose a device model
14 |
15 | 3. Select any layer to render it on the device's screen. For best results select a layer that's close to the device's screen dimensions (or the same aspect ratio). If it's not exact that's fine, images will be placed similar to Figma's 'Fill' setting for image fills. To create a frame with the right dimensions click the "Create Empty Frame" button.
16 |
17 | 4. To change the camera angle select an angle preset or click and drag over the device. Alternatively, you can set an exact rotation in degrees on the right. You can also modify the rotation of the model itself there.
18 |
19 | 5. Choose a device color. If you have local color styles click the color swatch to choose one, or manually enter a hex code.
20 |
21 | 6. Click "Save as Image" to render the current view as an image layer in Figma.
22 |
23 | ## Install & run
24 |
25 | Make sure you have nodejs and yarn installed. Install dependencies with:
26 |
27 | ```bash
28 | yarn
29 | ```
30 |
31 | Once it's done start up a local server with:
32 |
33 | ```bash
34 | yarn start
35 | ```
36 |
37 | To run get a production-ready build:
38 |
39 | ```bash
40 | yarn build
41 | ```
42 |
--------------------------------------------------------------------------------
/craco.config.js:
--------------------------------------------------------------------------------
1 | const postcssOptions = require('./postcss.config');
2 |
3 | module.exports = {
4 | style: {
5 | postcss: {
6 | mode: 'extends',
7 | ...postcssOptions,
8 | },
9 | },
10 | };
11 |
--------------------------------------------------------------------------------
/design/Google Pixel 6.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/Google Pixel 6.glb
--------------------------------------------------------------------------------
/design/iMac 2021.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/iMac 2021.glb
--------------------------------------------------------------------------------
/design/iMac Pro.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/iMac Pro.glb
--------------------------------------------------------------------------------
/design/iPad Air 3.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/iPad Air 3.glb
--------------------------------------------------------------------------------
/design/iPhone 11.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/iPhone 11.glb
--------------------------------------------------------------------------------
/design/iPhone 12.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/iPhone 12.glb
--------------------------------------------------------------------------------
/design/imac-2021.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/imac-2021.jpg
--------------------------------------------------------------------------------
/design/imac-pro.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/imac-pro.jpg
--------------------------------------------------------------------------------
/design/ipad-air.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/ipad-air.jpg
--------------------------------------------------------------------------------
/design/iphone-11.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/iphone-11.jpg
--------------------------------------------------------------------------------
/design/iphone-12.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/iphone-12.jpg
--------------------------------------------------------------------------------
/design/macbook-pro.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/macbook-pro.jpg
--------------------------------------------------------------------------------
/design/pixel-6.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/design/pixel-6.jpg
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src",
4 | "module": "commonjs"
5 | },
6 | "include": ["src", ".storybook/StoryContainer.js"]
7 | }
8 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Device Models",
3 | "id": "906973799344127422",
4 | "api": "1.0.0",
5 | "main": "build-plugin/figma.js",
6 | "ui": "build-plugin/index.html",
7 | "editorType": ["figma"]
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "device-models",
3 | "version": "2.1.1",
4 | "homepage": "https://devicemodels.com",
5 | "description": "A Figma plugin to create mockups with 3D device models.",
6 | "repository": "https://github.com/CodyJasonBennett/device-models.git",
7 | "author": "Cody Bennett ",
8 | "license": "MIT",
9 | "private": true,
10 | "devDependencies": {
11 | "@babel/core": "^7.15.5",
12 | "@craco/craco": "^6.3.0",
13 | "@storybook/addon-a11y": "^6.3.8",
14 | "@storybook/addon-actions": "^6.3.8",
15 | "@storybook/addon-controls": "^6.3.8",
16 | "@storybook/react": "^6.3.8",
17 | "@testing-library/jest-dom": "^5.14.1",
18 | "@testing-library/react": "^12.1.0",
19 | "@testing-library/user-event": "^13.2.1",
20 | "enzyme": "^3.11.0",
21 | "enzyme-adapter-react-16": "^1.15.5",
22 | "html-webpack-inline-source-plugin": "1.0.0-beta.2",
23 | "html-webpack-plugin": "^4.0.0-alpha",
24 | "prettier": "^2.4.1",
25 | "react-scripts": "4.0.3",
26 | "react-snap": "1.23.0",
27 | "source-map-explorer": "^2.5.2",
28 | "storybook-preset-craco": "^0.0.6",
29 | "stylelint": "^13.13.1",
30 | "stylelint-config-recommended": "^5.0.0",
31 | "webpack-cli": "^4.8.0"
32 | },
33 | "dependencies": {
34 | "@react-spring/three": "^9.2.4",
35 | "@react-three/drei": "^7.11.0",
36 | "@react-three/fiber": "^7.0.7",
37 | "camera-controls": "^1.32.3",
38 | "classnames": "^2.3.1",
39 | "react": "^17.0.2",
40 | "react-dom": "^17.0.2",
41 | "react-helmet": "^6.1.0",
42 | "react-router-dom": "5.3.0",
43 | "react-transition-group": "^4.4.2",
44 | "three": "^0.132.2"
45 | },
46 | "scripts": {
47 | "start": "set PORT=80 && craco start",
48 | "start-plugin": "set NODE_ENV=development&&webpack --watch",
49 | "build": "craco build",
50 | "postbuild": "react-snap",
51 | "build-plugin": "set NODE_ENV=production&&webpack",
52 | "build-storybook": "build-storybook -o build-storybook",
53 | "test": "craco test",
54 | "storybook": "start-storybook -p 9009 -s public",
55 | "analyze": "source-map-explorer 'build/static/js/*.js'"
56 | },
57 | "reactSnap": {
58 | "puppeteerArgs": [
59 | "--no-sandbox",
60 | "--disable-setuid-sandbox"
61 | ],
62 | "skipThirdPartyRequests": true,
63 | "headless": true,
64 | "crawl": true
65 | },
66 | "browserslist": {
67 | "production": [
68 | ">10%",
69 | "not dead",
70 | "not ie 11",
71 | "not op_mini all"
72 | ],
73 | "development": [
74 | "last 1 chrome version",
75 | "last 1 firefox version",
76 | "last 1 safari version"
77 | ]
78 | },
79 | "proxy": "https://devicemodels.com"
80 | }
81 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('postcss-flexbugs-fixes'),
4 | require('postcss-preset-env')({
5 | autoprefixer: {
6 | flexbox: 'no-2009',
7 | },
8 | stage: 3,
9 | features: {
10 | 'nesting-rules': true,
11 | 'custom-media-queries': {
12 | importFrom: 'src/app/index.css',
13 | },
14 | },
15 | }),
16 | ],
17 | };
18 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/public/favicon.png
--------------------------------------------------------------------------------
/public/humans.txt:
--------------------------------------------------------------------------------
1 | The humans.txt file explains the team, technology,
2 | and graphic assets behind this site humanstxt.org.
3 |
4 | _______________________________________________________________________________
5 |
6 | DESIGNER
7 |
8 | Cody Bennett
9 | Multidisciplinary Designer & Developer
10 |
11 | Design tools:
12 | Figma
13 |
14 | github.com/CodyJasonBennett
15 |
16 | _______________________________________________________________________________
17 |
18 | TECHNOLOGY
19 |
20 | React
21 | Storybook
22 | Three
23 | Popmotion
24 |
25 | _______________________________________________________________________________
26 |
27 | FONTS
28 |
29 | Inter
30 |
--------------------------------------------------------------------------------
/public/icon-256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/public/icon-256.png
--------------------------------------------------------------------------------
/public/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/public/icon-512.png
--------------------------------------------------------------------------------
/public/icon.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
20 |
21 |
22 |
23 |
24 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
41 |
45 |
46 |
47 |
48 |
49 |
50 |
54 |
58 |
59 |
60 |
61 |
62 |
66 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "devicemodels.com",
3 | "name": "Device Models",
4 | "icons": [
5 | {
6 | "src": "favicon.png",
7 | "sizes": "64x64",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "icon-256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | },
15 | {
16 | "src": "icon-512.png",
17 | "sizes": "512x512",
18 | "type": "image/png"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#18A0FB",
24 | "background_color": "#18A0FB"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 |
--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | https://devicemodels.com/
5 | 2021-09-03
6 | monthly
7 | 0.8
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/social-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/public/social-image.png
--------------------------------------------------------------------------------
/src/app/index.css:
--------------------------------------------------------------------------------
1 | @custom-media --mediaDesktop (max-width: 2080px);
2 | @custom-media --mediaLaptop (max-width: 1680px);
3 | @custom-media --mediaTablet (max-width: 1024px);
4 | @custom-media --mediaMobile (max-width: 696px);
5 | @custom-media --mediaMobileS (max-width: 400px);
6 | @custom-media --mediaUseMotion (prefers-reduced-motion: no-preference);
7 | @custom-media --mediaReduceMotion (prefers-reduced-motion: reduce);
8 |
9 | body {
10 | font-family: var(--fontStack);
11 | font-weight: var(--fontWeightRegular);
12 | font-synthesis: none;
13 | text-rendering: optimizeLegibility;
14 | color: var(--colorTextBody);
15 | background: rgb(var(--rgbSurface));
16 | border: 0;
17 | margin: 0;
18 | width: 100vw;
19 | overflow-x: hidden;
20 | }
21 |
22 | ::selection {
23 | background: rgb(var(--rgbPrimary));
24 | color: rgb(var(--rgbWhite));
25 | }
26 |
27 | @keyframes fade-in {
28 | 0% {
29 | opacity: 0;
30 | }
31 | 100% {
32 | opacity: 1;
33 | }
34 | }
35 |
36 | .app {
37 | width: 100%;
38 | position: relative;
39 | background: rgb(var(--rgbBackground));
40 | transition: background var(--durationM) ease;
41 | outline: none;
42 | display: grid;
43 | grid-template: 100% / 100%;
44 | }
45 |
46 | .app__page {
47 | grid-area: 1 / 1;
48 | min-height: 100vh;
49 | }
50 |
51 | .skip-to-main {
52 | color: rgb(var(--rgbWhite));
53 | z-index: 128;
54 |
55 | &:focus {
56 | padding: var(--spaceS) var(--spaceM);
57 | position: fixed;
58 | top: var(--spaceM);
59 | left: var(--spaceM);
60 | text-decoration: none;
61 | font-weight: var(--fontWeightMedium);
62 | line-height: 1;
63 | box-shadow: 0 0 0 4px rgb(var(--rgbBackground)), 0 0 0 8px rgb(var(--rgbText));
64 | outline: none;
65 | }
66 |
67 | &::before {
68 | content: '';
69 | position: absolute;
70 | inset: 0;
71 | background-color: rgb(var(--rgbPrimary));
72 | z-index: -1;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/app/index.js:
--------------------------------------------------------------------------------
1 | import { lazy, Suspense, useEffect, createContext, Fragment } from 'react';
2 | import { BrowserRouter, Switch, Route, useLocation } from 'react-router-dom';
3 | import { Transition, TransitionGroup } from 'react-transition-group';
4 | import classNames from 'classnames';
5 | import { Helmet } from 'react-helmet';
6 | import ThemeProvider from 'components/ThemeProvider';
7 | import VisuallyHidden from 'components/VisuallyHidden';
8 | import { tokens } from 'components/ThemeProvider/theme';
9 | import { msToNum } from 'utils/style';
10 | import { reflow } from 'utils/transition';
11 | import prerender from 'utils/prerender';
12 | import './reset.css';
13 | import './index.css';
14 |
15 | const Home = lazy(() => import('pages/Home'));
16 | const Page404 = lazy(() => import('pages/404'));
17 |
18 | export const TransitionContext = createContext();
19 |
20 | const repoPrompt = `\u00A9 2020-${new Date().getFullYear()} Cody Bennett\n\nCheck out the source code: https://github.com/CodyJasonBennett/device-models`;
21 |
22 | const App = () => {
23 | useEffect(() => {
24 | if (!prerender) {
25 | console.info(`${repoPrompt}\n\n`);
26 | }
27 |
28 | window.history.scrollRestoration = 'manual';
29 | }, []);
30 |
31 | return (
32 |
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | const AppRoutes = () => {
41 | const location = useLocation();
42 | const { pathname } = location;
43 |
44 | return (
45 |
46 |
47 |
48 |
49 |
50 | Skip to main content
51 |
52 |
53 |
58 | {status => (
59 |
60 |
61 | }>
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | )}
70 |
71 |
72 |
73 | );
74 | };
75 |
76 | export default App;
77 |
--------------------------------------------------------------------------------
/src/app/index.test.js:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react';
2 | import App from '.';
3 |
4 | test('renders without crashing', () => {
5 | render();
6 | });
7 |
--------------------------------------------------------------------------------
/src/app/reset.css:
--------------------------------------------------------------------------------
1 | body {
2 | box-sizing: border-box;
3 | }
4 |
5 | *,
6 | *::before,
7 | *::after {
8 | box-sizing: inherit;
9 | }
10 |
11 | h1,
12 | h2,
13 | h3,
14 | h4,
15 | h5,
16 | p {
17 | margin: 0;
18 | }
19 |
20 | input,
21 | textarea {
22 | font-family: inherit;
23 | border: 0;
24 | margin: 0;
25 | padding: 0;
26 | background-color: transparent;
27 | appearance: none;
28 | -webkit-appearance: none;
29 | border-radius: 0;
30 | }
31 |
32 | button {
33 | margin: 0;
34 | border: 0;
35 | font-family: inherit;
36 | background-color: transparent;
37 | appearance: none;
38 | }
39 |
40 | a {
41 | text-decoration: none;
42 | }
43 |
44 | ul {
45 | margin: 0;
46 | padding-left: 1em;
47 | }
48 |
--------------------------------------------------------------------------------
/src/assets/fonts/inter-bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/fonts/inter-bold.woff2
--------------------------------------------------------------------------------
/src/assets/fonts/inter-medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/fonts/inter-medium.woff2
--------------------------------------------------------------------------------
/src/assets/fonts/inter-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/fonts/inter-regular.woff2
--------------------------------------------------------------------------------
/src/assets/imac-2021-front-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-2021-front-left.png
--------------------------------------------------------------------------------
/src/assets/imac-2021-front-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-2021-front-right.png
--------------------------------------------------------------------------------
/src/assets/imac-2021-front.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-2021-front.png
--------------------------------------------------------------------------------
/src/assets/imac-2021-tilted-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-2021-tilted-left.png
--------------------------------------------------------------------------------
/src/assets/imac-2021-tilted-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-2021-tilted-right.png
--------------------------------------------------------------------------------
/src/assets/imac-2021.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-2021.glb
--------------------------------------------------------------------------------
/src/assets/imac-2021.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-2021.jpg
--------------------------------------------------------------------------------
/src/assets/imac-pro-front-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-pro-front-left.png
--------------------------------------------------------------------------------
/src/assets/imac-pro-front-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-pro-front-right.png
--------------------------------------------------------------------------------
/src/assets/imac-pro-front.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-pro-front.png
--------------------------------------------------------------------------------
/src/assets/imac-pro-tilted-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-pro-tilted-left.png
--------------------------------------------------------------------------------
/src/assets/imac-pro-tilted-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-pro-tilted-right.png
--------------------------------------------------------------------------------
/src/assets/imac-pro.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-pro.glb
--------------------------------------------------------------------------------
/src/assets/imac-pro.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/imac-pro.jpg
--------------------------------------------------------------------------------
/src/assets/ipad-air-front-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/ipad-air-front-left.png
--------------------------------------------------------------------------------
/src/assets/ipad-air-front-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/ipad-air-front-right.png
--------------------------------------------------------------------------------
/src/assets/ipad-air-front.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/ipad-air-front.png
--------------------------------------------------------------------------------
/src/assets/ipad-air-tilted-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/ipad-air-tilted-left.png
--------------------------------------------------------------------------------
/src/assets/ipad-air-tilted-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/ipad-air-tilted-right.png
--------------------------------------------------------------------------------
/src/assets/ipad-air.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/ipad-air.glb
--------------------------------------------------------------------------------
/src/assets/ipad-air.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/ipad-air.jpg
--------------------------------------------------------------------------------
/src/assets/iphone-11-front-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-11-front-left.png
--------------------------------------------------------------------------------
/src/assets/iphone-11-front-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-11-front-right.png
--------------------------------------------------------------------------------
/src/assets/iphone-11-front.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-11-front.png
--------------------------------------------------------------------------------
/src/assets/iphone-11-tilted-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-11-tilted-left.png
--------------------------------------------------------------------------------
/src/assets/iphone-11-tilted-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-11-tilted-right.png
--------------------------------------------------------------------------------
/src/assets/iphone-11.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-11.glb
--------------------------------------------------------------------------------
/src/assets/iphone-11.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-11.jpg
--------------------------------------------------------------------------------
/src/assets/iphone-12-front-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-12-front-left.png
--------------------------------------------------------------------------------
/src/assets/iphone-12-front-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-12-front-right.png
--------------------------------------------------------------------------------
/src/assets/iphone-12-front.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-12-front.png
--------------------------------------------------------------------------------
/src/assets/iphone-12-tilted-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-12-tilted-left.png
--------------------------------------------------------------------------------
/src/assets/iphone-12-tilted-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-12-tilted-right.png
--------------------------------------------------------------------------------
/src/assets/iphone-12.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-12.glb
--------------------------------------------------------------------------------
/src/assets/iphone-12.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/iphone-12.jpg
--------------------------------------------------------------------------------
/src/assets/macbook-pro-front-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/macbook-pro-front-left.png
--------------------------------------------------------------------------------
/src/assets/macbook-pro-front-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/macbook-pro-front-right.png
--------------------------------------------------------------------------------
/src/assets/macbook-pro-front.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/macbook-pro-front.png
--------------------------------------------------------------------------------
/src/assets/macbook-pro-tilted-left.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/macbook-pro-tilted-left.png
--------------------------------------------------------------------------------
/src/assets/macbook-pro-tilted-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/macbook-pro-tilted-right.png
--------------------------------------------------------------------------------
/src/assets/macbook-pro.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/macbook-pro.glb
--------------------------------------------------------------------------------
/src/assets/macbook-pro.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodyJasonBennett/device-models/7d6704b19809e23ae8dfb2e8dc9eb1fa1cce2345/src/assets/macbook-pro.jpg
--------------------------------------------------------------------------------
/src/components/Button/index.css:
--------------------------------------------------------------------------------
1 | .button {
2 | border-radius: 5px;
3 | background-color: rgba(var(--rgbSurface));
4 | color: rgba(var(--rgbText));
5 | border: none;
6 | padding: 0 16px 1px;
7 | margin: 0;
8 | box-shadow: inset 0 0 0 1px rgba(var(--rgbText));
9 | outline: none;
10 | cursor: pointer;
11 | height: 30px;
12 | display: flex;
13 | align-items: center;
14 | justify-content: center;
15 | text-decoration: none;
16 | transition-property: background, color, box-shadow;
17 | transition-duration: 0.3s;
18 | transition-timing-function: ease;
19 | }
20 |
21 | .button:focus {
22 | box-shadow: inset 0 0 0 2px rgba(var(--rgbPrimary));
23 | }
24 |
25 | .button:disabled {
26 | opacity: 0.2;
27 | cursor: default;
28 | pointer-events: none;
29 | }
30 |
31 | .button--icon-only {
32 | padding: 0;
33 | width: 30px;
34 | box-shadow: none;
35 | background-color: transparent;
36 | }
37 |
38 | .button--icon-only[aria-pressed='true'] {
39 | color: rgba(var(--rgbPrimary));
40 | }
41 |
42 | .button--icon-only:hover {
43 | background: rgba(var(--rgbText), 0.1);
44 | }
45 |
46 | .button--icon-only:focus {
47 | box-shadow: inset 0 0 0 2px rgba(var(--rgbText), 0.2);
48 | }
49 |
50 | .button--icon-only svg {
51 | align-self: center;
52 | justify-self: center;
53 | flex: 0 0 auto;
54 | position: absolute;
55 | }
56 |
57 | .button--primary {
58 | box-shadow: none;
59 | background: rgba(var(--rgbPrimary));
60 | color: white;
61 | }
62 |
63 | .button--primary:focus {
64 | box-shadow: inset 0 0 0 2px rgba(var(--rgbBlack), 0.3);
65 | }
66 |
67 | .button--grey {
68 | box-shadow: none;
69 | background-color: transparent;
70 | }
71 |
72 | .button--grey:hover {
73 | background: rgba(var(--rgbText), 0.1);
74 | }
75 |
76 | .button--grey:focus {
77 | box-shadow: inset 0 0 0 2px rgba(var(--rgbText), 0.2);
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/Button/index.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import Icon from 'components/Icon';
3 | import { blurOnMouseUp } from 'utils/focus';
4 | import './index.css';
5 |
6 | const Button = ({
7 | as: Component = 'button',
8 | className,
9 | primary,
10 | iconOnly,
11 | grey,
12 | icon,
13 | children,
14 | ...rest
15 | }) => (
16 |
25 | {!iconOnly && children}
26 | {icon && }
27 |
28 | );
29 |
30 | export default Button;
31 |
--------------------------------------------------------------------------------
/src/components/Button/index.stories.js:
--------------------------------------------------------------------------------
1 | import { action } from '@storybook/addon-actions';
2 | import Button from 'components/Button';
3 | import { StoryContainer } from '../../../.storybook/StoryContainer';
4 |
5 | export default {
6 | title: 'Button',
7 | };
8 |
9 | export const normal = () => (
10 |
11 |
12 |
13 | );
14 |
15 | export const primary = () => (
16 |
17 |
20 |
21 | );
22 |
23 | export const grey = () => (
24 |
25 |
28 |
29 | );
30 |
31 | export const iconOnly = () => (
32 |
33 |
34 |
35 | );
36 |
--------------------------------------------------------------------------------
/src/components/Dropdown/index.css:
--------------------------------------------------------------------------------
1 | .dropdown {
2 | position: static;
3 | }
4 |
5 | .dropdown__input {
6 | position: relative;
7 | background-color: transparent;
8 | border: 0;
9 | margin: 0;
10 | padding: 0;
11 | font-size: 11px;
12 | text-align: start;
13 | cursor: pointer;
14 | display: flex;
15 | align-items: center;
16 | transition-property: background, box-shadow;
17 | transition-timing-function: ease;
18 | transition-duration: 0.3s;
19 | outline: none;
20 | padding: 0 8px;
21 | border-radius: 2px;
22 | height: 32px;
23 | width: 100%;
24 | color: rgba(var(--rgbText));
25 | }
26 |
27 | .dropdown__input:hover {
28 | background-color: rgba(var(--rgbSurface));
29 | box-shadow: inset 0 0 0 1px rgba(var(--rgbText), 0.2);
30 | }
31 |
32 | .dropdown__input:focus {
33 | background-color: rgba(var(--rgbSurface));
34 | box-shadow: inset 0 0 0 2px rgba(var(--rgbPrimary));
35 | }
36 |
37 | .dropdown__input-chevron,
38 | .dropdown__input-chevron-active {
39 | transition-property: opacity, transform;
40 | transition-timing-function: var(--bezierFastoutSlowin);
41 | transition-duration: 0.3s;
42 | }
43 |
44 | .dropdown__input-chevron {
45 | margin-left: 4px;
46 | opacity: 0.5;
47 | transition-delay: 50ms;
48 | flex: 0 0 auto;
49 | }
50 |
51 | .dropdown__input-chevron-active {
52 | opacity: 0;
53 | transform: translate3d(-4px, 0, 0);
54 | position: absolute;
55 | right: 8px;
56 | transition-delay: 0s;
57 | }
58 |
59 | .dropdown__input:hover .dropdown__input-chevron-active,
60 | .dropdown__input:focus .dropdown__input-chevron-active {
61 | opacity: 1;
62 | transform: none;
63 | transition-delay: 50ms;
64 | }
65 |
66 | .dropdown__input:hover .dropdown__input-chevron,
67 | .dropdown__input:focus .dropdown__input-chevron {
68 | opacity: 0;
69 | transform: translate3d(4px, 0, 0);
70 | transition-delay: 0s;
71 | }
72 |
73 | /* Don't transition the transforms when inline */
74 | .dropdown__input--inline .dropdown__input-chevron,
75 | .dropdown__input--inline .dropdown__input-chevron-active,
76 | .dropdown__input--inline:hover .dropdown__input-chevron,
77 | .dropdown__input--inline:focus .dropdown__input-chevron,
78 | .dropdown__input--inline:hover .dropdown__input-chevron-active,
79 | .dropdown__input--inline:focus .dropdown__input-chevron-active {
80 | transition-property: opacity;
81 | transition-delay: 0s;
82 | transform: none;
83 | }
84 |
85 | .dropdown__input > span {
86 | overflow: hidden;
87 | text-overflow: ellipsis;
88 | white-space: nowrap;
89 | }
90 |
91 | .dropdown__menu-container {
92 | position: fixed;
93 | transform: translate3d(0, -8px, 0);
94 | opacity: 0;
95 | transition-property: opacity, transform;
96 | transition-duration: 0.3s;
97 | transition-timing-function: var(--bezierFastoutSlowin);
98 | display: flex;
99 | flex-direction: column;
100 | background-color: rgba(var(--rgbSurfaceDark));
101 | box-shadow: 0 0 0 1px rgba(var(--rgbBlack), 0.1);
102 | box-shadow: 0px 5px 17px rgba(var(--rgbBlack), 0.2),
103 | 0px 2px 7px rgba(var(--rgbBlack), 0.15);
104 | padding: 8px 0;
105 | border-radius: 2px;
106 | width: 200px;
107 | z-index: 2048;
108 | box-sizing: border-box;
109 | max-height: calc(100vh - 20px);
110 | overflow-y: auto;
111 | }
112 |
113 | .dropdown__menu-container::-webkit-scrollbar-track {
114 | background: none;
115 | border-radius: 10px;
116 | }
117 |
118 | .dropdown__menu-container::-webkit-scrollbar-thumb {
119 | border-radius: 10px;
120 | background-color: transparent;
121 | background-clip: padding-box;
122 | border: 4px solid transparent;
123 | }
124 |
125 | .dropdown__menu-container::-webkit-scrollbar {
126 | width: 14px;
127 | height: 14px;
128 | }
129 |
130 | .dropdown__menu-container:hover::-webkit-scrollbar-thumb {
131 | background-color: rgba(var(--rgbWhite), 0.3);
132 | }
133 |
134 | .dropdown__menu-container--entering,
135 | .dropdown__menu-container--entered {
136 | transform: none;
137 | opacity: 1;
138 | }
139 |
140 | .dropdown__menu-item {
141 | position: relative;
142 | border: 0;
143 | margin: 0;
144 | padding: 0 8px 0 32px;
145 | height: 32px;
146 | background: none;
147 | font-family: inherit;
148 | font-size: 12px;
149 | text-align: left;
150 | text-decoration: none;
151 | color: rgba(var(--rgbWhite), 0.9);
152 | transition-property: background, box-shadow;
153 | transition-duration: 0.3s;
154 | transition-timing-function: ease;
155 | cursor: pointer;
156 | display: flex;
157 | flex: 0 0 auto;
158 | align-items: center;
159 | outline: none;
160 | max-width: 100%;
161 | min-width: 0;
162 | }
163 |
164 | .dropdown__menu-item > span {
165 | overflow: hidden;
166 | white-space: nowrap;
167 | text-overflow: ellipsis;
168 | }
169 |
170 | .dropdown__menu-item:focus {
171 | background: rgba(var(--rgbWhite), 0.1);
172 | }
173 |
174 | .dropdown__menu-item:hover {
175 | background: rgba(var(--rgbPrimary), 1);
176 | }
177 |
178 | .dropdown__icon {
179 | margin-right: 4px;
180 | }
181 |
182 | .dropdown__menu-item-check {
183 | position: absolute;
184 | left: 8px;
185 | }
186 |
187 | .dropdown__divider {
188 | margin: 8px 0;
189 | border-bottom: 1px solid rgba(var(--rgbWhite), 0.2);
190 | }
191 |
192 | .dropdown__header {
193 | font-size: 10px;
194 | font-weight: 600;
195 | letter-spacing: 0.08em;
196 | text-transform: uppercase;
197 | height: 32px;
198 | display: flex;
199 | align-items: center;
200 | padding: 8px;
201 | color: rgba(var(--rgbWhite), 0.7);
202 | }
203 |
--------------------------------------------------------------------------------
/src/components/Dropdown/index.js:
--------------------------------------------------------------------------------
1 | import { useRef, useState, Fragment } from 'react';
2 | import classNames from 'classnames';
3 | import { Transition } from 'react-transition-group';
4 | import Icon from 'components/Icon';
5 | import { useId } from 'hooks';
6 | import { reflow } from 'utils/transition';
7 | import './index.css';
8 |
9 | const Dropdown = ({ options, onChange }) => {
10 | const dropdown = useRef();
11 | const [defaultValue] = options;
12 | const [value, setValue] = useState(defaultValue);
13 | const [expanded, setExpanded] = useState(false);
14 | const id = useId();
15 | const dropdownId = `dropdown-button-${id}`;
16 |
17 | const updateValue = option => {
18 | setValue(option);
19 | if (onChange) onChange(option);
20 |
21 | setExpanded(false);
22 | };
23 |
24 | const getMenuCoords = () => {
25 | const dropdownRef = dropdown.current;
26 | if (!dropdownRef) return;
27 |
28 | const top = dropdownRef.offsetHeight + dropdownRef.offsetTop + 8;
29 | const left = dropdownRef.offsetRight;
30 |
31 | return { top, left };
32 | };
33 |
34 | const onClick = event => {
35 | event.preventDefault();
36 | event.stopPropagation();
37 |
38 | const handleClick = () => {
39 | setExpanded(false);
40 | document.removeEventListener('click', handleClick);
41 | };
42 |
43 | document.addEventListener('click', handleClick);
44 | setExpanded(true);
45 |
46 | return () => {
47 | document.removeEventListener('click', handleClick);
48 | };
49 | };
50 |
51 | return (
52 |
53 |
54 |
65 |
66 | {expanded && (
67 |
68 | {status => (
69 |
79 | {options.map(option => (
80 |
93 | ))}
94 |
95 | )}
96 |
97 | )}
98 |
99 | );
100 | };
101 |
102 | export default Dropdown;
103 |
--------------------------------------------------------------------------------
/src/components/Dropdown/index.stories.js:
--------------------------------------------------------------------------------
1 | import { action } from '@storybook/addon-actions';
2 | import Dropdown from 'components/Dropdown';
3 | import { StoryContainer } from '../../../.storybook/StoryContainer';
4 |
5 | export default {
6 | title: 'Dropdown',
7 | };
8 |
9 | export const dropdown = () => (
10 |
11 |
15 |
16 | );
17 |
--------------------------------------------------------------------------------
/src/components/Heading/index.css:
--------------------------------------------------------------------------------
1 | .heading {
2 | display: block;
3 | line-height: var(--lineHeightTitle);
4 | color: var(--colorTextTitle);
5 | }
6 |
7 | .heading--level-0 {
8 | letter-spacing: -0.006em;
9 | font-size: var(--fontSizeH0);
10 | }
11 |
12 | .heading--level-1 {
13 | letter-spacing: -0.005em;
14 | font-size: var(--fontSizeH1);
15 | }
16 |
17 | .heading--level-2 {
18 | font-size: var(--fontSizeH2);
19 | letter-spacing: -0.003em;
20 | }
21 |
22 | .heading--level-3 {
23 | font-size: var(--fontSizeH3);
24 | }
25 |
26 | .heading--level-4 {
27 | font-size: var(--fontSizeH4);
28 | }
29 |
30 | .heading--align-auto {
31 | text-align: inherit;
32 | }
33 |
34 | .heading--align-start {
35 | text-align: start;
36 | }
37 |
38 | .heading--align-center {
39 | text-align: center;
40 | }
41 |
42 | .heading--weight-regular {
43 | font-weight: var(--fontWeightRegular);
44 | }
45 |
46 | .heading--weight-medium {
47 | font-weight: var(--fontWeightMedium);
48 | }
49 |
50 | .heading--weight-bold {
51 | font-weight: var(--fontWeightBold);
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/Heading/index.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import './index.css';
3 |
4 | const Heading = ({
5 | children,
6 | level = 1,
7 | as,
8 | align = 'auto',
9 | weight = 'medium',
10 | className,
11 | ...rest
12 | }) => {
13 | const clampedLevel = Math.min(Math.max(level, 0), 4);
14 | const Component = as || `h${Math.max(clampedLevel, 1)}`;
15 |
16 | return (
17 |
27 | {children}
28 |
29 | );
30 | };
31 |
32 | export default Heading;
33 |
--------------------------------------------------------------------------------
/src/components/Heading/index.stories.js:
--------------------------------------------------------------------------------
1 | import Heading from 'components/Heading';
2 | import { StoryContainer } from '../../../.storybook/StoryContainer';
3 |
4 | export default {
5 | title: 'Heading',
6 | };
7 |
8 | export const level = () => (
9 |
10 | Heading 0
11 | Heading 1
12 | Heading 2
13 | Heading 3
14 | Heading 4
15 |
16 | );
17 |
18 | export const weight = () => (
19 |
20 |
21 | Regular
22 |
23 |
24 | Medium
25 |
26 |
27 | Bold
28 |
29 |
30 | );
31 |
32 | export const align = () => (
33 |
34 |
35 | Start
36 |
37 |
38 | Center
39 |
40 |
41 | );
42 |
--------------------------------------------------------------------------------
/src/components/Icon/icons.js:
--------------------------------------------------------------------------------
1 | const icons = {
2 | logo: props => (
3 |
26 | ),
27 | chevron: props => (
28 |
34 | ),
35 | check: props => (
36 |
42 | ),
43 | figma: props => (
44 |
50 | ),
51 | github: props => (
52 |
55 | ),
56 | email: props => (
57 |
60 | ),
61 | rotateX: props => (
62 |
68 | ),
69 | rotateY: props => (
70 |
76 | ),
77 | rotateZ: props => (
78 |
81 | ),
82 | settings: props => (
83 |
89 | ),
90 | };
91 |
92 | export default icons;
93 |
--------------------------------------------------------------------------------
/src/components/Icon/index.css:
--------------------------------------------------------------------------------
1 | .icon {
2 | display: block;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/Icon/index.js:
--------------------------------------------------------------------------------
1 | import icons from './icons';
2 | import './index.css';
3 |
4 | const Icon = ({ icon, ...rest }) => {
5 | const IconComponent = icons[icon];
6 |
7 | return ;
8 | };
9 |
10 | export default Icon;
11 | export { icons };
12 |
--------------------------------------------------------------------------------
/src/components/Icon/index.stories.js:
--------------------------------------------------------------------------------
1 | import Icon, { icons } from 'components/Icon';
2 | import { StoryContainer } from '../../../.storybook/StoryContainer';
3 |
4 | export default {
5 | title: 'Icons',
6 | };
7 |
8 | export const Icons = () => {
9 | return (
10 |
11 | {Object.keys(icons).map(key => (
12 |
13 | ))}
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/src/components/Input/index.css:
--------------------------------------------------------------------------------
1 | .input {
2 | display: grid;
3 | justify-items: flex-start;
4 | position: relative;
5 | }
6 |
7 | .input__label {
8 | font-size: 11px;
9 | font-weight: 600;
10 | color: rgba(var(--rgbText));
11 | padding: 0 8px;
12 | margin-bottom: 6px;
13 | }
14 |
15 | .input__element {
16 | border: none;
17 | outline: none;
18 | padding: 8px;
19 | padding-right: 0;
20 | margin: 0;
21 | justify-self: stretch;
22 | min-width: 0;
23 | border-radius: 2px;
24 | transition: box-shadow 0.3s ease;
25 | background-color: rgba(var(--rgbSurface));
26 | height: 32px;
27 | }
28 |
29 | .input__element::-webkit-inner-spin-button {
30 | -webkit-appearance: none;
31 | }
32 |
33 | .input__element:hover {
34 | box-shadow: inset 0 0 0 1px rgba(var(--rgbText), 0.1);
35 | }
36 |
37 | .input__element:focus {
38 | box-shadow: inset 0 0 0 2px rgba(var(--rgbPrimary));
39 | }
40 |
41 | .input__content {
42 | position: relative;
43 | display: grid;
44 | }
45 |
46 | .input__color-swatch {
47 | width: 16px;
48 | height: 16px;
49 | border-radius: 2px;
50 | position: absolute;
51 | left: 8px;
52 | bottom: 8px;
53 | box-shadow: inset 0 0 0 1px rgba(var(--rgbText), 0.05);
54 | border: 0;
55 | margin: 0;
56 | padding: 0;
57 | cursor: pointer;
58 | outline: none;
59 | transition: box-shadow 0.3s ease;
60 | }
61 |
62 | .input__color-swatch:focus {
63 | box-shadow: inset 0 0 0 1px rgba(var(--rgbText), 0.05),
64 | 0 0 0 2px rgba(var(--rgbText), 0.2);
65 | }
66 |
67 | .input__icon {
68 | width: 16px;
69 | height: 16px;
70 | position: absolute;
71 | left: 8px;
72 | bottom: 8px;
73 | color: rgba(var(--rgbText), 0.6);
74 | pointer-events: none;
75 | }
76 |
77 | .input__dragger {
78 | width: 32px;
79 | height: 32px;
80 | position: absolute;
81 | display: flex;
82 | align-items: center;
83 | justify-content: center;
84 | cursor: ew-resize;
85 | }
86 |
87 | .input__dragger .input__icon {
88 | position: static;
89 | }
90 |
91 | .input__icon + .input__element,
92 | .input__dragger + .input__element,
93 | .input__color-swatch + .input__element,
94 | .dropdown + .input__element,
95 | .option-menu + .input__element {
96 | padding-left: 32px;
97 | }
98 |
--------------------------------------------------------------------------------
/src/components/Input/index.js:
--------------------------------------------------------------------------------
1 | import Icon from 'components/Icon';
2 | import { useId } from 'hooks';
3 | import './index.css';
4 |
5 | const Input = ({ style, icon, label, type = 'text', ...rest }) => {
6 | const id = useId();
7 | const inputId = `${id}-input`;
8 |
9 | return (
10 |
11 | {icon && }
12 |
19 |
20 | );
21 | };
22 |
23 | export default Input;
24 |
--------------------------------------------------------------------------------
/src/components/Input/index.stories.js:
--------------------------------------------------------------------------------
1 | import Input from 'components/Input';
2 | import { StoryContainer } from '../../../.storybook/StoryContainer';
3 |
4 | export default {
5 | title: 'Input',
6 | };
7 |
8 | export const input = () => (
9 |
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/src/components/Link/index.css:
--------------------------------------------------------------------------------
1 | .link {
2 | --lineStrokeWidth: 2px;
3 | --linkColor: var(--rgbPrimary);
4 | --lineOpacity: 0.3;
5 | --filledLineGradient: linear-gradient(rgb(var(--linkColor)), rgb(var(--linkColor)));
6 | --unfilledLineGradient: linear-gradient(
7 | rgb(var(--linkColor) / var(--lineOpacity)),
8 | rgb(var(--linkColor) / var(--lineOpacity))
9 | );
10 |
11 | outline: none;
12 | cursor: pointer;
13 | display: inline;
14 | color: rgb(var(--linkColor));
15 | background: var(--filledLineGradient) no-repeat 100% 100% / 0 var(--lineStrokeWidth),
16 | var(--unfilledLineGradient) no-repeat 0 100% / 100% var(--lineStrokeWidth);
17 | padding-bottom: var(--lineStrokeWidth);
18 |
19 | &:hover,
20 | &:focus {
21 | background: var(--filledLineGradient) no-repeat 0 100% / 100% var(--lineStrokeWidth),
22 | var(--unfilledLineGradient) no-repeat 0 100% / 100% var(--lineStrokeWidth);
23 | }
24 |
25 | &:focus {
26 | box-shadow: 0 0 0 4px rgb(var(--rgbBackground)), 0 0 0 8px rgb(var(--rgbText));
27 | }
28 |
29 | &:active {
30 | box-shadow: none;
31 | }
32 |
33 | @media (--mediaUseMotion) {
34 | & {
35 | transition-duration: var(--durationM);
36 | transition-timing-function: var(--bezierFastoutSlowin);
37 | transition-property: background-size;
38 | }
39 | }
40 | }
41 |
42 | .link--secondary {
43 | --linkColor: var(--rgbText);
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/Link/index.js:
--------------------------------------------------------------------------------
1 | import { Link as RouterLink } from 'react-router-dom';
2 | import classNames from 'classnames';
3 | import { blurOnMouseUp } from 'utils/focus';
4 | import './index.css';
5 |
6 | // File extensions that can be linked to
7 | const VALID_EXT = ['txt', 'png', 'jpg'];
8 |
9 | const Link = ({ rel, target, children, secondary, className, href, as, ...rest }) => {
10 | const isValidExtension = VALID_EXT.includes(href?.split('.').pop());
11 | const isAnchor = href?.includes('://') || href?.[0] === '#' || isValidExtension;
12 | const relValue = rel || isAnchor ? 'noreferrer noopener' : undefined;
13 | const targetValue = target || isAnchor ? '_blank' : undefined;
14 | const Component = as || isAnchor ? 'a' : RouterLink;
15 |
16 | return (
17 |
26 | {children}
27 |
28 | );
29 | };
30 |
31 | export default Link;
32 |
--------------------------------------------------------------------------------
/src/components/Link/index.stories.js:
--------------------------------------------------------------------------------
1 | import Link from 'components/Link';
2 | import { StoryContainer } from '../../../.storybook/StoryContainer';
3 |
4 | export default {
5 | title: 'Links',
6 | };
7 |
8 | export const links = () => (
9 |
10 | e.preventDefault()}>
11 | Primary Link
12 |
13 | e.preventDefault()}>
14 | Secondary link
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/src/components/Option/index.css:
--------------------------------------------------------------------------------
1 | .option-menu {
2 | position: static;
3 | }
4 |
5 | .option-menu__input {
6 | position: relative;
7 | background-color: transparent;
8 | border: 0;
9 | margin: 0;
10 | padding: 0;
11 | font-size: 11px;
12 | text-align: start;
13 | cursor: pointer;
14 | display: flex;
15 | align-items: center;
16 | transition-property: background, box-shadow;
17 | transition-timing-function: ease;
18 | transition-duration: 0.3s;
19 | outline: none;
20 | padding: 0 8px;
21 | border-radius: 2px;
22 | height: 32px;
23 | width: 100%;
24 | color: rgba(var(--rgbText));
25 | }
26 |
27 | .option-menu__input:hover {
28 | background-color: rgba(var(--rgbSurface));
29 | box-shadow: inset 0 0 0 1px rgba(var(--rgbText), 0.2);
30 | }
31 |
32 | .option-menu__input:focus {
33 | background-color: rgba(var(--rgbSurface));
34 | box-shadow: inset 0 0 0 2px rgba(var(--rgbPrimary));
35 | }
36 |
37 | .option-menu__input-chevron,
38 | .option-menu__input-chevron-active {
39 | transition-property: opacity, transform;
40 | transition-timing-function: var(--bezierFastoutSlowin);
41 | transition-duration: 0.3s;
42 | }
43 |
44 | .option-menu__input-chevron {
45 | margin-left: 4px;
46 | opacity: 0.5;
47 | transition-delay: 50ms;
48 | flex: 0 0 auto;
49 | }
50 |
51 | .option-menu__input-chevron-active {
52 | opacity: 0;
53 | transform: translate3d(-4px, 0, 0);
54 | position: absolute;
55 | right: 8px;
56 | transition-delay: 0s;
57 | }
58 |
59 | .option-menu__input:hover .option-menu__input-chevron-active,
60 | .option-menu__input:focus .option-menu__input-chevron-active {
61 | opacity: 1;
62 | transform: none;
63 | transition-delay: 50ms;
64 | }
65 |
66 | .option-menu__input:hover .option-menu__input-chevron,
67 | .option-menu__input:focus .option-menu__input-chevron {
68 | opacity: 0;
69 | transform: translate3d(4px, 0, 0);
70 | transition-delay: 0s;
71 | }
72 |
73 | /* Don't transition the transforms when inline */
74 | .option-menu__input--inline .option-menu__input-chevron,
75 | .option-menu__input--inline .option-menu__input-chevron-active,
76 | .option-menu__input--inline:hover .option-menu__input-chevron,
77 | .option-menu__input--inline:focus .option-menu__input-chevron,
78 | .option-menu__input--inline:hover .option-menu__input-chevron-active,
79 | .option-menu__input--inline:focus .option-menu__input-chevron-active {
80 | transition-property: opacity;
81 | transition-delay: 0s;
82 | transform: none;
83 | }
84 |
85 | @media (prefers-reduced-motion: reduce) {
86 | .option-menu__input-chevron,
87 | .option-menu__input-chevron-active {
88 | transition-property: opacity;
89 | transform: none;
90 | }
91 | }
92 |
93 | .option-menu__input > span {
94 | overflow: hidden;
95 | text-overflow: ellipsis;
96 | white-space: nowrap;
97 | }
98 |
99 | .option-menu__menu-container {
100 | position: fixed;
101 | transform: translate3d(0, -8px, 0);
102 | opacity: 0;
103 | transition-property: opacity, transform;
104 | transition-duration: 0.3s;
105 | transition-timing-function: var(--bezierFastoutSlowin);
106 | display: flex;
107 | flex-direction: column;
108 | background-color: rgba(var(--rgbSurfaceDark));
109 | box-shadow: 0px 5px 17px rgba(var(--rgbBlack), 0.2),
110 | 0px 2px 7px rgba(var(--rgbBlack), 0.15);
111 | padding: 8px 0;
112 | border-radius: 2px;
113 | width: 200px;
114 | z-index: 2048;
115 | box-sizing: border-box;
116 | max-height: calc(100vh - 20px);
117 | overflow-y: auto;
118 | }
119 |
120 | .option-menu__menu-container::-webkit-scrollbar-track {
121 | background: none;
122 | border-radius: 10px;
123 | }
124 |
125 | .option-menu__menu-container::-webkit-scrollbar-thumb {
126 | border-radius: 10px;
127 | background-color: transparent;
128 | background-clip: padding-box;
129 | border: 4px solid transparent;
130 | }
131 |
132 | .option-menu__menu-container::-webkit-scrollbar {
133 | width: 14px;
134 | height: 14px;
135 | }
136 |
137 | .option-menu__menu-container:hover::-webkit-scrollbar-thumb {
138 | background-color: rgba(var(--rgbWhite), 0.3);
139 | }
140 |
141 | .option-menu__menu-container--entering,
142 | .option-menu__menu-container--entered {
143 | transform: none;
144 | opacity: 1;
145 | }
146 |
147 | @media (prefers-reduced-motion: reduce) {
148 | .option-menu__menu-container {
149 | transition-property: opacity;
150 | transform: none;
151 | }
152 | }
153 |
154 | .option-menu__menu-item {
155 | position: relative;
156 | border: 0;
157 | margin: 0;
158 | padding: 0 8px 0 32px;
159 | height: 32px;
160 | background: none;
161 | font-family: inherit;
162 | font-size: 12px;
163 | text-align: left;
164 | text-decoration: none;
165 | color: rgba(var(--rgbWhite), 0.9);
166 | transition-property: background, box-shadow;
167 | transition-duration: 0.3s;
168 | transition-timing-function: ease;
169 | cursor: pointer;
170 | display: flex;
171 | flex: 0 0 auto;
172 | align-items: center;
173 | outline: none;
174 | max-width: 100%;
175 | min-width: 0;
176 | }
177 |
178 | .option-menu__menu-item > span {
179 | overflow: hidden;
180 | white-space: nowrap;
181 | text-overflow: ellipsis;
182 | }
183 |
184 | .option-menu__menu-item:focus {
185 | background: rgba(var(--rgbWhite), 0.1);
186 | }
187 |
188 | .option-menu__menu-item:hover {
189 | background: rgba(var(--rgbPrimary), 1);
190 | }
191 |
192 | .option-menu__icon {
193 | margin-right: 4px;
194 | }
195 |
196 | .option-menu__menu-item-check {
197 | position: absolute;
198 | left: 8px;
199 | }
200 |
201 | .option-menu__divider {
202 | margin: 8px 0;
203 | border-bottom: 1px solid rgba(var(--rgbWhite), 0.2);
204 | }
205 |
206 | .option-menu__header {
207 | font-size: 10px;
208 | font-weight: var(--fontWeightSemiBold);
209 | letter-spacing: 0.08em;
210 | text-transform: uppercase;
211 | height: 32px;
212 | display: flex;
213 | align-items: center;
214 | padding: 8px;
215 | color: rgba(var(--rgbWhite), 0.7);
216 | }
217 |
--------------------------------------------------------------------------------
/src/components/Option/index.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { useState, Fragment, useRef, useEffect } from 'react';
3 | import { Transition } from 'react-transition-group';
4 | import Icon from 'components/Icon';
5 | import Button from 'components/Button';
6 | import { useId } from 'hooks';
7 | import { reflow } from 'utils/transition';
8 | import { blurOnMouseUp } from 'utils/focus';
9 | import './index.css';
10 |
11 | export const OptionMenuDivider = () => ;
12 |
13 | export const OptionMenuHeader = ({ children }) => (
14 | {children}
15 | );
16 |
17 | export const OptionMenuItem = ({ selected = false, children, ...rest }) => (
18 |
28 | );
29 |
30 | export const Option = ({ as, className, children, ...rest }) => {
31 | const parent = useRef();
32 | const menu = useRef();
33 | const Component = as || Button;
34 | const [expanded, setExpanded] = useState(false);
35 | const [offset, setOffset] = useState();
36 | const id = useId();
37 | const optionId = `optionMenu-button-${id}`;
38 |
39 | useEffect(() => {
40 | if (!expanded || offset) return;
41 |
42 | const optionRef = parent.current;
43 | const menuRef = menu.current;
44 | if (!optionRef || !menuRef) return;
45 |
46 | const top = optionRef.offsetTop - menuRef.clientHeight - 8;
47 | const left = optionRef.offsetLeft + optionRef.clientWidth - menuRef.clientWidth;
48 |
49 | setOffset({ top, left });
50 | }, [expanded, offset]);
51 |
52 | const onClick = event => {
53 | event.preventDefault();
54 | event.stopPropagation();
55 |
56 | const handleClick = () => {
57 | setExpanded(!expanded);
58 | document.removeEventListener('click', handleClick);
59 | };
60 |
61 | document.addEventListener('click', handleClick);
62 | setExpanded(!expanded);
63 |
64 | return () => {
65 | document.removeEventListener('click', handleClick);
66 | };
67 | };
68 |
69 | return (
70 |
71 |
72 |
81 |
82 | {expanded && (
83 |
84 | {status => (
85 |
97 | {children}
98 |
99 | )}
100 |
101 | )}
102 |
103 | );
104 | };
105 |
--------------------------------------------------------------------------------
/src/components/Scene/Canvas.css:
--------------------------------------------------------------------------------
1 | .canvas-container {
2 | position: relative;
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | overflow: hidden;
7 | }
8 |
9 | .canvas {
10 | position: absolute;
11 | inset: 0;
12 | outline: none;
13 | cursor: grab;
14 | opacity: 0;
15 | animation: fade-in 0.4s ease forwards;
16 | }
17 |
18 | .canvas:active {
19 | cursor: grabbing;
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/Scene/Canvas.js:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, Component, useRef, useState, Suspense } from 'react';
2 | import { render, unmountComponentAtNode } from '@react-three/fiber';
3 | import './Canvas.css';
4 |
5 | const Block = ({ set }) => {
6 | useLayoutEffect(() => {
7 | set(new Promise(() => null));
8 | return () => set(false);
9 | }, [set]);
10 |
11 | return null;
12 | };
13 |
14 | class ErrorBoundary extends Component {
15 | state = { error: false };
16 | static getDerivedStateFromError = () => ({ error: true });
17 | componentDidCatch(error) {
18 | this.props.set(error);
19 | }
20 | render() {
21 | return this.state.error ? null : this.props.children;
22 | }
23 | }
24 |
25 | const Canvas = ({ children, ...props }) => {
26 | const container = useRef();
27 | const canvas = useRef();
28 | const [block, setBlock] = useState();
29 | const [error, setError] = useState();
30 |
31 | // Suspend this component if block is a promise (2nd run)
32 | if (block) throw block;
33 | // Throw exception outwards if anything within canvas throws
34 | if (error) throw error;
35 |
36 | // Render to canvas
37 | useLayoutEffect(() => {
38 | render(
39 |
40 | }>{children}
41 | ,
42 | canvas.current,
43 | props
44 | );
45 | // eslint-disable-next-line react-hooks/exhaustive-deps
46 | }, [children]);
47 |
48 | // Cleanup on unmount
49 | useLayoutEffect(() => {
50 | const canvasRef = canvas.current;
51 | return () => unmountComponentAtNode(canvasRef);
52 | }, []);
53 |
54 | return (
55 |
56 |
57 |
58 | );
59 | };
60 |
61 | export default Canvas;
62 |
--------------------------------------------------------------------------------
/src/components/Scene/Controls.js:
--------------------------------------------------------------------------------
1 | import { useMemo, useEffect, useRef } from 'react';
2 | import {
3 | MOUSE,
4 | Vector2,
5 | Vector3,
6 | Vector4,
7 | Quaternion,
8 | Matrix4,
9 | Spherical,
10 | Box3,
11 | Sphere,
12 | Raycaster,
13 | MathUtils,
14 | } from 'three';
15 | import CameraControls from 'camera-controls';
16 | import { extend, useThree, useFrame } from '@react-three/fiber';
17 | import { usePrefersReducedMotion } from 'hooks';
18 |
19 | CameraControls.install({
20 | THREE: {
21 | MOUSE,
22 | Vector2,
23 | Vector3,
24 | Vector4,
25 | Quaternion,
26 | Matrix4,
27 | Spherical,
28 | Box3,
29 | Sphere,
30 | Raycaster,
31 | MathUtils,
32 | },
33 | });
34 |
35 | extend({ CameraControls });
36 |
37 | const Controls = ({ cameraRotation, onUpdate, ...rest }) => {
38 | const { camera, gl } = useThree();
39 | const controls = useMemo(
40 | () => new CameraControls(camera, gl.domElement),
41 | [camera, gl.domElement]
42 | );
43 | const animating = useRef(false);
44 | const transition = useRef(false);
45 | const reduceMotion = usePrefersReducedMotion();
46 |
47 | useEffect(() => {
48 | if (animating.current) return;
49 |
50 | let timeout;
51 |
52 | const onSleep = () => {
53 | controls.removeEventListener('sleep', onSleep);
54 | transition.current = false;
55 | };
56 |
57 | const handleUpdate = () => {
58 | timeout = null;
59 | transition.current = true;
60 |
61 | controls.rotateTo(
62 | MathUtils.degToRad(Number(cameraRotation.y)) % 360,
63 | MathUtils.degToRad(Number(cameraRotation.x)) % 360,
64 | !reduceMotion
65 | );
66 |
67 | controls.addEventListener('sleep', onSleep);
68 | };
69 |
70 | clearTimeout(timeout);
71 | timeout = setTimeout(handleUpdate, 250);
72 |
73 | return () => {
74 | controls.removeEventListener('sleep', onSleep);
75 | };
76 | }, [reduceMotion, controls, cameraRotation.y, cameraRotation.x]);
77 |
78 | useFrame((_, delta) => {
79 | const needsUpdate = controls.update(delta);
80 |
81 | if (!needsUpdate || !onUpdate || transition.current) {
82 | return (animating.current = false);
83 | }
84 |
85 | const cameraRotation = {
86 | x: Math.round(MathUtils.radToDeg(controls.polarAngle) % 360),
87 | y: Math.round(MathUtils.radToDeg(controls.azimuthAngle) % 360),
88 | };
89 |
90 | animating.current = true;
91 |
92 | return onUpdate(cameraRotation);
93 | });
94 |
95 | return ;
96 | };
97 |
98 | export default Controls;
99 |
--------------------------------------------------------------------------------
/src/components/Scene/Model.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { Color, sRGBEncoding, MathUtils } from 'three';
3 | import { useGLTF, useTexture } from '@react-three/drei';
4 | import { useThree } from '@react-three/fiber';
5 | import { useSpring, animated } from '@react-spring/three';
6 | import { usePrefersReducedMotion } from 'hooks';
7 | import deviceModels from './deviceModels';
8 |
9 | const modelColor = new Color();
10 |
11 | const Model = ({ model, selection, color = '#FFFFFF', modelRotation, ...rest }) => {
12 | const targetModel = deviceModels[model];
13 | const url = targetModel?.url || model;
14 | const gltf = useGLTF(url);
15 | const texture = useTexture(selection || targetModel?.texture);
16 | const { gl } = useThree();
17 | const reduceMotion = usePrefersReducedMotion();
18 | const { rotation } = useSpring({
19 | immediate: reduceMotion,
20 | rotation: [
21 | MathUtils.degToRad(Number(modelRotation.x)),
22 | MathUtils.degToRad(Number(modelRotation.y)),
23 | MathUtils.degToRad(Number(modelRotation.z)),
24 | ],
25 | });
26 |
27 | useEffect(() => {
28 | modelColor.set(color);
29 |
30 | gltf.scene.traverse(node => {
31 | if (node.material && node.name !== 'Screen') {
32 | node.material.color = modelColor;
33 | }
34 | });
35 | }, [color, gltf.scene]);
36 |
37 | useEffect(() => {
38 | texture.encoding = sRGBEncoding;
39 | texture.flipY = false;
40 | texture.anisotropy = gl.capabilities.getMaxAnisotropy();
41 |
42 | // Decode the texture to prevent jank on first render
43 | gl.initTexture(texture);
44 |
45 | gltf.scene.traverse(node => {
46 | if (node.name === 'Screen') {
47 | node.material.color = new Color(0xffffff);
48 | node.material.transparent = false;
49 | node.material.map = texture;
50 | node.material.needsUpdate = true;
51 | }
52 | });
53 | }, [texture, gl, gltf.scene]);
54 |
55 | return ;
56 | };
57 |
58 | export default Model;
59 |
--------------------------------------------------------------------------------
/src/components/Scene/deviceModels.js:
--------------------------------------------------------------------------------
1 | import iphone11 from 'assets/iphone-11.glb';
2 | import iphone11Texture from 'assets/iphone-11.jpg';
3 | import iphone11FrontLeft from 'assets/iphone-11-front-left.png';
4 | import iphone11Front from 'assets/iphone-11-front.png';
5 | import iphone11FrontRight from 'assets/iphone-11-front-right.png';
6 | import iphone11TiltedLeft from 'assets/iphone-11-tilted-left.png';
7 | import iphone11TiltedRight from 'assets/iphone-11-tilted-right.png';
8 | import iphone12 from 'assets/iphone-12.glb';
9 | import iphone12Texture from 'assets/iphone-12.jpg';
10 | import iphone12FrontLeft from 'assets/iphone-12-front-left.png';
11 | import iphone12Front from 'assets/iphone-12-front.png';
12 | import iphone12FrontRight from 'assets/iphone-12-front-right.png';
13 | import iphone12TiltedLeft from 'assets/iphone-12-tilted-left.png';
14 | import iphone12TiltedRight from 'assets/iphone-12-tilted-right.png';
15 | import macbookPro from 'assets/macbook-pro.glb';
16 | import macbookProTexture from 'assets/macbook-pro.jpg';
17 | import macbookProFrontLeft from 'assets/macbook-pro-front-left.png';
18 | import macbookProFront from 'assets/macbook-pro-front.png';
19 | import macbookProFrontRight from 'assets/macbook-pro-front-right.png';
20 | import macbookProTiltedLeft from 'assets/macbook-pro-tilted-left.png';
21 | import macbookProTiltedRight from 'assets/macbook-pro-tilted-right.png';
22 | import iMac2021 from 'assets/imac-2021.glb';
23 | import iMac2021Texture from 'assets/imac-2021.jpg';
24 | import iMac2021FrontLeft from 'assets/imac-2021-front-left.png';
25 | import iMac2021Front from 'assets/imac-2021-front.png';
26 | import iMac2021FrontRight from 'assets/imac-2021-front-right.png';
27 | import iMac2021TiltedLeft from 'assets/imac-2021-tilted-left.png';
28 | import iMac2021TiltedRight from 'assets/imac-2021-tilted-right.png';
29 | import iMacPro from 'assets/imac-pro.glb';
30 | import iMacProTexture from 'assets/imac-pro.jpg';
31 | import iMacProFrontLeft from 'assets/imac-pro-front-left.png';
32 | import iMacProFront from 'assets/imac-pro-front.png';
33 | import iMacProFrontRight from 'assets/imac-pro-front-right.png';
34 | import iMacProTiltedLeft from 'assets/imac-pro-tilted-left.png';
35 | import iMacProTiltedRight from 'assets/imac-pro-tilted-right.png';
36 | import iPadAir from 'assets/ipad-air.glb';
37 | import iPadAirTexture from 'assets/ipad-air.jpg';
38 | import iPadAirFrontLeft from 'assets/ipad-air-front-left.png';
39 | import iPadAirFront from 'assets/ipad-air-front.png';
40 | import iPadAirFrontRight from 'assets/ipad-air-front-right.png';
41 | import iPadAirTiltedLeft from 'assets/ipad-air-tilted-left.png';
42 | import iPadAirTiltedRight from 'assets/ipad-air-tilted-right.png';
43 |
44 | const models = {
45 | 'iPhone 11': {
46 | name: 'iPhone 11',
47 | url: iphone11,
48 | width: 375,
49 | height: 812,
50 | texture: iphone11Texture,
51 | renders: [
52 | iphone11FrontLeft,
53 | iphone11Front,
54 | iphone11FrontRight,
55 | iphone11TiltedLeft,
56 | iphone11TiltedRight,
57 | ],
58 | },
59 | 'iPhone 12': {
60 | name: 'iPhone 12',
61 | url: iphone12,
62 | width: 530,
63 | height: 1148,
64 | texture: iphone12Texture,
65 | renders: [
66 | iphone12FrontLeft,
67 | iphone12Front,
68 | iphone12FrontRight,
69 | iphone12TiltedLeft,
70 | iphone12TiltedRight,
71 | ],
72 | },
73 | 'Macbook Pro': {
74 | name: 'Macbook Pro',
75 | url: macbookPro,
76 | width: 1280,
77 | height: 800,
78 | texture: macbookProTexture,
79 | renders: [
80 | macbookProFrontLeft,
81 | macbookProFront,
82 | macbookProFrontRight,
83 | macbookProTiltedLeft,
84 | macbookProTiltedRight,
85 | ],
86 | },
87 | 'iMac 2021': {
88 | name: 'iMac 2021',
89 | url: iMac2021,
90 | width: 2240,
91 | height: 1260,
92 | texture: iMac2021Texture,
93 | renders: [
94 | iMac2021FrontLeft,
95 | iMac2021Front,
96 | iMac2021FrontRight,
97 | iMac2021TiltedLeft,
98 | iMac2021TiltedRight,
99 | ],
100 | },
101 | 'iMac Pro': {
102 | name: 'iMac Pro',
103 | url: iMacPro,
104 | width: 2560,
105 | height: 1440,
106 | texture: iMacProTexture,
107 | renders: [
108 | iMacProFrontLeft,
109 | iMacProFront,
110 | iMacProFrontRight,
111 | iMacProTiltedLeft,
112 | iMacProTiltedRight,
113 | ],
114 | },
115 | 'iPad Air': {
116 | name: 'iPad Air',
117 | url: iPadAir,
118 | width: 820,
119 | height: 1180,
120 | texture: iPadAirTexture,
121 | renders: [
122 | iPadAirFrontLeft,
123 | iPadAirFront,
124 | iPadAirFrontRight,
125 | iPadAirTiltedLeft,
126 | iPadAirTiltedRight,
127 | ],
128 | },
129 | };
130 |
131 | export default models;
132 |
--------------------------------------------------------------------------------
/src/components/Scene/index.js:
--------------------------------------------------------------------------------
1 | import { useContext, useCallback } from 'react';
2 | import Canvas from './Canvas';
3 | import Model from './Model';
4 | import Controls from './Controls';
5 | import { PluginContext } from 'plugin';
6 | import exportSettings from 'data/export';
7 |
8 | const Scene = ({
9 | clay,
10 | model = 'iPhone 11',
11 | environment = 'studio',
12 | controls,
13 | ...rest
14 | }) => {
15 | const { dispatch } = useContext(PluginContext);
16 |
17 | const onCreated = useCallback(
18 | ({ gl, scene, camera }) => {
19 | gl.physicallyCorrectLights = true;
20 |
21 | const requestOutputFrame = exportQuality => {
22 | // Get export settings
23 | const pixelRatio = gl.getPixelRatio();
24 | const exportRatio = exportSettings[exportQuality];
25 |
26 | // Render
27 | gl.setPixelRatio(exportRatio);
28 | gl.render(scene, camera);
29 | const render = gl.domElement.toDataURL('image/png', 1);
30 |
31 | // Cleanup
32 | gl.setPixelRatio(pixelRatio);
33 |
34 | return render;
35 | };
36 |
37 | dispatch({ type: 'setRequestOutputFrame', value: requestOutputFrame });
38 | },
39 | [dispatch]
40 | );
41 |
42 | return (
43 |
66 | );
67 | };
68 |
69 | export default Scene;
70 |
--------------------------------------------------------------------------------
/src/components/Spinner/index.css:
--------------------------------------------------------------------------------
1 | @keyframes spinner-rotate {
2 | 100% {
3 | transform: rotate(360deg) translate3d(0, 0, 0);
4 | }
5 | }
6 |
7 | .ui__spinner {
8 | position: absolute;
9 | inset: 0 240px 0 0;
10 | opacity: 0;
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 | background-color: rgb(var(--rgbSurface));
15 | transition: opacity 0.4s ease 0s;
16 | }
17 |
18 | .ui__spinner--entered {
19 | opacity: 1;
20 | }
21 |
22 | .spinner__element {
23 | fill: none;
24 | display: block;
25 | animation: spinner-rotate 1s linear infinite;
26 | transform-origin: center center;
27 | }
28 |
29 | .spinner__path {
30 | stroke: currentColor;
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/Spinner/index.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { Transition } from 'react-transition-group';
3 | import { reflow } from 'utils/transition';
4 | import './index.css';
5 |
6 | const Spinner = () => (
7 |
8 | {status => (
9 |
10 |
11 |
29 |
30 |
31 | )}
32 |
33 | );
34 |
35 | export default Spinner;
36 |
--------------------------------------------------------------------------------
/src/components/Spinner/index.stories.js:
--------------------------------------------------------------------------------
1 | import Spinner from 'components/Spinner';
2 | import { StoryContainer } from '../../../.storybook/StoryContainer';
3 |
4 | export default {
5 | title: 'Spinner',
6 | };
7 |
8 | export const spinner = () => (
9 |
10 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/src/components/Text/index.css:
--------------------------------------------------------------------------------
1 | .text {
2 | line-height: var(--lineHeightBody);
3 | color: var(--colorTextBody);
4 | }
5 |
6 | .text--size-s {
7 | font-size: var(--fontSizeBodyS);
8 | }
9 |
10 | .text--size-m {
11 | font-size: var(--fontSizeBodyM);
12 | }
13 |
14 | .text--size-l {
15 | font-size: var(--fontSizeBodyL);
16 | }
17 |
18 | .text--size-xl {
19 | font-size: var(--fontSizeBodyXL);
20 | }
21 |
22 | .text--align-auto {
23 | text-align: inherit;
24 | }
25 |
26 | .text--align-start {
27 | text-align: start;
28 | }
29 |
30 | .text--align-center {
31 | text-align: center;
32 | }
33 |
34 | .text--weight-auto {
35 | font-weight: inherit;
36 | }
37 |
38 | .text--weight-regular {
39 | font-weight: var(--fontWeightRegular);
40 | }
41 |
42 | .text--weight-medium {
43 | font-weight: var(--fontWeightMedium);
44 | }
45 |
46 | .text--weight-bold {
47 | font-weight: var(--fontWeightBold);
48 | }
49 |
50 | .text--secondary {
51 | color: var(--colorTextLight);
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/Text/index.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import './index.css';
3 |
4 | const Text = ({
5 | children,
6 | size = 'm',
7 | as: Component = 'p',
8 | align = 'auto',
9 | weight = 'auto',
10 | secondary,
11 | className,
12 | ...rest
13 | }) => {
14 | return (
15 |
28 | {children}
29 |
30 | );
31 | };
32 |
33 | export default Text;
34 |
--------------------------------------------------------------------------------
/src/components/Text/index.stories.js:
--------------------------------------------------------------------------------
1 | import Text from 'components/Text';
2 | import { StoryContainer } from '../../../.storybook/StoryContainer';
3 |
4 | export default {
5 | title: 'Text',
6 | };
7 |
8 | export const size = () => (
9 |
10 | XLarge
11 | Large
12 | Medium
13 | Small
14 |
15 | );
16 |
17 | export const weight = () => (
18 |
19 | Regular
20 | Medium
21 | Bold
22 |
23 | );
24 |
25 | export const align = () => (
26 |
27 | Start
28 | Center
29 |
30 | );
31 |
--------------------------------------------------------------------------------
/src/components/ThemeProvider/index.js:
--------------------------------------------------------------------------------
1 | import { createContext, useMemo, useEffect, Fragment } from 'react';
2 | import { Helmet } from 'react-helmet';
3 | import classNames from 'classnames';
4 | import useTheme from './useTheme';
5 | import { theme, tokens } from './theme';
6 | import { usePrefersColorScheme } from 'hooks';
7 | import { media } from 'utils/style';
8 | import InterRegular from 'assets/fonts/inter-regular.woff2';
9 | import InterMedium from 'assets/fonts/inter-medium.woff2';
10 | import InterBold from 'assets/fonts/inter-bold.woff2';
11 |
12 | export const fontStyles = `
13 | @font-face {
14 | font-family: "Inter";
15 | font-weight: 400;
16 | src: url(${InterRegular}) format("woff");
17 | font-display: swap;
18 | }
19 |
20 | @font-face {
21 | font-family: "Inter";
22 | font-weight: 500;
23 | src: url(${InterMedium}) format("woff");
24 | font-display: swap;
25 | }
26 |
27 | @font-face {
28 | font-family: "Inter";
29 | font-weight: 700;
30 | src: url(${InterBold}) format("woff2");
31 | font-display: swap;
32 | }
33 | `;
34 |
35 | const ThemeContext = createContext({});
36 |
37 | const ThemeProvider = ({
38 | themeId,
39 | theme: themeOverrides,
40 | children,
41 | className,
42 | as: Component = 'div',
43 | inline,
44 | }) => {
45 | const colorScheme = usePrefersColorScheme();
46 | const currentTheme = useMemo(() => {
47 | return { ...theme[colorScheme || themeId], ...themeOverrides };
48 | }, [colorScheme, themeId, themeOverrides]);
49 | const parentTheme = useTheme();
50 | const isRootProvider = inline || !parentTheme.themeId;
51 |
52 | // Save root theme id to localstorage and apply class to body
53 | useEffect(() => {
54 | if (isRootProvider) {
55 | document.body.classList.remove('light', 'dark');
56 | document.body.classList.add(currentTheme.themeId);
57 | }
58 | }, [isRootProvider, currentTheme]);
59 |
60 | return (
61 |
62 | {/* Add fonts and base tokens for the root provider */}
63 | {isRootProvider && (
64 |
65 | {inline && (
66 |
67 |
71 |
72 |
73 | )}
74 | {!inline && (
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | )}
83 | {children}
84 |
85 | )}
86 | {/* Nested providers need a div to override theme tokens */}
87 | {!isRootProvider && (
88 |
92 | {children}
93 |
94 | )}
95 |
96 | );
97 | };
98 |
99 | /**
100 | * Transform theme token objects into CSS custom property strings
101 | */
102 | function createThemeProperties(theme) {
103 | return Object.keys(theme)
104 | .filter(key => key !== 'themeId')
105 | .map(key => `--${key}: ${theme[key]};`)
106 | .join('\n');
107 | }
108 |
109 | /**
110 | * Transform theme tokens into a React CSSProperties object
111 | */
112 | function createThemeStyleObject(theme) {
113 | let style = {};
114 |
115 | for (const key of Object.keys(theme)) {
116 | if (key !== 'themeId') {
117 | style[`--${key}`] = theme[key];
118 | }
119 | }
120 |
121 | return style;
122 | }
123 |
124 | /**
125 | * Generate media queries for tokens
126 | */
127 | function createMediaTokenProperties() {
128 | return Object.keys(media)
129 | .map(key => {
130 | return `
131 | @media (max-width: ${media[key]}px) {
132 | :root {
133 | ${createThemeProperties(tokens[key])}
134 | }
135 | }
136 | `;
137 | })
138 | .join('\n');
139 | }
140 |
141 | export const tokenStyles = `
142 | :root {
143 | ${createThemeProperties(tokens.base)}
144 | }
145 |
146 | .dark {
147 | ${createThemeProperties(theme.dark)}
148 | }
149 |
150 | .light {
151 | ${createThemeProperties(theme.light)}
152 | }
153 |
154 | ${createMediaTokenProperties()}
155 | `;
156 |
157 | export {
158 | theme,
159 | useTheme,
160 | ThemeContext,
161 | createThemeProperties,
162 | createThemeStyleObject,
163 | createMediaTokenProperties,
164 | };
165 |
166 | export default ThemeProvider;
167 |
--------------------------------------------------------------------------------
/src/components/ThemeProvider/theme.js:
--------------------------------------------------------------------------------
1 | import { pxToRem } from 'utils/style';
2 |
3 | const systemFontStack =
4 | 'system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Ubuntu, Helvetica Neue, sans-serif';
5 |
6 | // Full list of tokens
7 | const baseTokens = {
8 | rgbBlack: '0, 0, 0',
9 | rgbWhite: '255, 255, 255',
10 | rgbGray: '36, 44, 57',
11 | rgbBackground: '255, 185, 97',
12 | rgbPrimary: '24, 160, 251',
13 | rgbAccent: '24, 160, 251',
14 | bezierFastoutSlowin: 'cubic-bezier(0.4, 0.0, 0.2, 1)',
15 | durationXS: '200ms',
16 | durationS: '300ms',
17 | durationM: '400ms',
18 | durationL: '600ms',
19 | durationXL: '800ms',
20 | systemFontStack,
21 | fontStack: `Inter, ${systemFontStack}`,
22 | fontWeightRegular: 400,
23 | fontWeightMedium: 500,
24 | fontWeightSemiBold: 600,
25 | fontWeightBold: 700,
26 | fontSizeH0: pxToRem(140),
27 | fontSizeH1: pxToRem(100),
28 | fontSizeH2: pxToRem(58),
29 | fontSizeH3: pxToRem(38),
30 | fontSizeH4: pxToRem(28),
31 | fontSizeBodyXL: pxToRem(22),
32 | fontSizeBodyL: pxToRem(20),
33 | fontSizeBodyM: pxToRem(18),
34 | fontSizeBodyS: pxToRem(16),
35 | fontSizeBodyXS: pxToRem(14),
36 | lineHeightTitle: '1.1',
37 | lineHeightBody: '1.4',
38 | maxWidthS: '540px',
39 | maxWidthM: '720px',
40 | maxWidthL: '1096px',
41 | maxWidthXL: '1680px',
42 | spaceOuter: '64px',
43 | spaceXS: '4px',
44 | spaceS: '8px',
45 | spaceM: '16px',
46 | spaceL: '24px',
47 | spaceXL: '32px',
48 | space2XL: '48px',
49 | space3XL: '64px',
50 | space4XL: '96px',
51 | space5XL: '128px',
52 | };
53 |
54 | // Tokens that change based on viewport size
55 | const tokensDesktop = {
56 | fontSizeH0: pxToRem(120),
57 | fontSizeH1: pxToRem(80),
58 | };
59 |
60 | const tokensLaptop = {
61 | maxWidthS: '480px',
62 | maxWidthM: '640px',
63 | maxWidthL: '1000px',
64 | maxWidthXL: '1100px',
65 | spaceOuter: '48px',
66 | fontSizeH0: pxToRem(100),
67 | fontSizeH1: pxToRem(70),
68 | fontSizeH2: pxToRem(52),
69 | fontSizeH3: pxToRem(36),
70 | fontSizeH4: pxToRem(26),
71 | };
72 |
73 | const tokensTablet = {
74 | fontSizeH0: pxToRem(80),
75 | fontSizeH1: pxToRem(60),
76 | fontSizeH2: pxToRem(48),
77 | fontSizeH3: pxToRem(32),
78 | fontSizeH4: pxToRem(24),
79 | };
80 |
81 | const tokensMobile = {
82 | spaceOuter: '24px',
83 | fontSizeH0: pxToRem(56),
84 | fontSizeH1: pxToRem(40),
85 | fontSizeH2: pxToRem(34),
86 | fontSizeH3: pxToRem(28),
87 | fontSizeH4: pxToRem(22),
88 | fontSizeBodyL: pxToRem(18),
89 | fontSizeBodyM: pxToRem(16),
90 | fontSizeBodyS: pxToRem(14),
91 | };
92 |
93 | const tokensMobileSmall = {
94 | spaceOuter: '16px',
95 | fontSizeH0: pxToRem(42),
96 | fontSizeH1: pxToRem(38),
97 | fontSizeH2: pxToRem(28),
98 | fontSizeH3: pxToRem(24),
99 | fontSizeH4: pxToRem(20),
100 | };
101 |
102 | export const tokens = {
103 | base: baseTokens,
104 | desktop: tokensDesktop,
105 | laptop: tokensLaptop,
106 | tablet: tokensTablet,
107 | mobile: tokensMobile,
108 | mobileS: tokensMobileSmall,
109 | };
110 |
111 | // Tokens that change based on theme
112 | const dark = {
113 | themeId: 'dark',
114 | rgbSurface: '34, 34, 34',
115 | rgbSurfaceDark: '22, 22, 22',
116 | rgbText: '200, 200, 200',
117 | rgbBorder: '60, 60, 60',
118 | rgbError: '255, 0, 60',
119 | colorTextTitle: 'rgb(var(--rgbGray), 1)',
120 | colorTextBody: 'rgb(var(--rgbGray), 0.8)',
121 | colorTextLight: 'rgb(var(--rgbGray), 0.6)',
122 | };
123 |
124 | const light = {
125 | themeId: 'light',
126 | rgbSurface: '255, 255, 255',
127 | rgbSurfaceDark: '34, 34, 34',
128 | rgbText: '51, 51, 51',
129 | rgbBorder: '229, 229, 229',
130 | rgbError: '210, 14, 60',
131 | colorTextTitle: 'rgb(var(--rgbGray), 1)',
132 | colorTextBody: 'rgb(var(--rgbGray), 0.7)',
133 | colorTextLight: 'rgb(var(--rgbGray), 0.6)',
134 | };
135 |
136 | export const theme = { light, dark };
137 |
--------------------------------------------------------------------------------
/src/components/ThemeProvider/useTheme.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { ThemeContext } from '.';
3 |
4 | function useTheme() {
5 | const currentTheme = useContext(ThemeContext);
6 | return currentTheme;
7 | }
8 |
9 | export default useTheme;
10 |
--------------------------------------------------------------------------------
/src/components/Tooltip/index.css:
--------------------------------------------------------------------------------
1 | .tooltip {
2 | --tooltip-background: rgba(var(--rgbSurfaceDark));
3 | --tooltip-color: rgba(var(--rgbWhite));
4 |
5 | position: fixed;
6 | background: var(--tooltip-background);
7 | border-radius: 2px;
8 | color: var(--tooltip-color);
9 | padding: 8px 12px;
10 | pointer-events: none;
11 | user-select: none;
12 | opacity: 0;
13 | transition-property: transform, opacity;
14 | transition-duration: 0.3s;
15 | transition-timing-function: var(--bezierFastoutSlowin);
16 | font-size: 11px;
17 | font-weight: 500;
18 | line-height: 1.2;
19 | white-space: nowrap;
20 | display: flex;
21 | align-items: center;
22 | z-index: 1;
23 | }
24 |
25 | .tooltip::before {
26 | content: '';
27 | width: 0;
28 | height: 0;
29 | position: absolute;
30 | }
31 |
32 | .tooltip--top {
33 | transform: translate3d(-50%, 6px, 0);
34 | }
35 |
36 | .tooltip--top::before {
37 | border-left: 6px solid transparent;
38 | border-right: 6px solid transparent;
39 | border-top: 6px solid var(--tooltip-background);
40 | transform: translate3d(-50%, 100%, 0);
41 | left: 50%;
42 | bottom: 0;
43 | }
44 |
45 | .tooltip--entering.tooltip--top,
46 | .tooltip--entered.tooltip--top {
47 | transform: translate3d(-50%, 0, 0);
48 | }
49 |
50 | .tooltip--right {
51 | transform: translate3d(-6px, -50%, 0);
52 | }
53 |
54 | .tooltip--right::before {
55 | border-top: 6px solid transparent;
56 | border-bottom: 6px solid transparent;
57 | border-right: 6px solid var(--tooltip-background);
58 | transform: translate3d(-100%, -50%, 0);
59 | left: 0;
60 | top: 50%;
61 | }
62 |
63 | .tooltip--entering.tooltip--right,
64 | .tooltip--entered.tooltip--right {
65 | transform: translate3d(0, -50%, 0);
66 | }
67 |
68 | .tooltip--bottom {
69 | transform: translate3d(-50%, -6px, 0);
70 | }
71 |
72 | .tooltip--bottom::before {
73 | border-left: 6px solid transparent;
74 | border-right: 6px solid transparent;
75 | border-bottom: 6px solid var(--tooltip-background);
76 | transform: translate3d(-50%, -100%, 0);
77 | inset: 0 0 0 50%;
78 | }
79 |
80 | .tooltip--entering.tooltip--bottom,
81 | .tooltip--entered.tooltip--bottom {
82 | transform: translate3d(-50%, 0, 0);
83 | }
84 |
85 | .tooltip--left {
86 | transform: translate3d(6px, -50%, 0);
87 | }
88 |
89 | .tooltip--left::before {
90 | border-top: 6px solid transparent;
91 | border-bottom: 6px solid transparent;
92 | border-left: 6px solid var(--tooltip-background);
93 | transform: translate3d(100%, -50%, 0);
94 | right: 0;
95 | top: 50%;
96 | }
97 |
98 | .tooltip--entering.tooltip--left,
99 | .tooltip--entered.tooltip--left {
100 | transform: translate3d(0, -50%, 0);
101 | }
102 |
103 | .tooltip--entering,
104 | .tooltip--entered {
105 | opacity: 1;
106 | }
107 |
--------------------------------------------------------------------------------
/src/components/Tooltip/index.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { Transition } from 'react-transition-group';
3 | import { reflow } from 'utils/transition';
4 | import offset from 'utils/offset';
5 | import './index.css';
6 |
7 | const Tooltip = ({ id, visible, top, bottom, parent, children }) => (
8 |
9 | {status => (
10 |
19 | {children}
20 |
21 | )}
22 |
23 | );
24 |
25 | export default Tooltip;
26 |
--------------------------------------------------------------------------------
/src/components/Tooltip/index.stories.js:
--------------------------------------------------------------------------------
1 | import { useRef, useState } from 'react';
2 | import Tooltip from 'components/Tooltip';
3 | import { StoryContainer } from '../../../.storybook/StoryContainer';
4 |
5 | const TooltipWrapper = props => {
6 | const parent = useRef();
7 | const [visible, setVisible] = useState(false);
8 |
9 | return (
10 |
11 | setVisible(true)}
14 | onFocus={() => setVisible(true)}
15 | onMouseOut={() => setVisible(false)}
16 | onBlur={() => setVisible(false)}
17 | style={{ cursor: 'help' }}
18 | >
19 | Hover
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default {
27 | title: 'Tooltip',
28 | };
29 |
30 | export const normal = () => Normal;
31 |
32 | export const top = () => Top;
33 |
34 | export const bottom = () => Bottom;
35 |
--------------------------------------------------------------------------------
/src/components/VisuallyHidden/index.css:
--------------------------------------------------------------------------------
1 | .visually-hidden {
2 | position: absolute;
3 | }
4 |
5 | .visually-hidden--hidden,
6 | .visually-hidden--show-on-focus:not(:focus) {
7 | border: 0;
8 | clip: rect(0 0 0 0);
9 | height: 1px;
10 | width: 1px;
11 | margin: -1px;
12 | padding: 0;
13 | overflow: hidden;
14 | position: absolute;
15 | white-space: nowrap;
16 | word-wrap: normal;
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/VisuallyHidden/index.js:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import './index.css';
3 |
4 | const VisuallyHidden = ({
5 | className,
6 | showOnFocus,
7 | as: Component = 'span',
8 | children,
9 | visible,
10 | ...rest
11 | }) => {
12 | return (
13 |
20 | {children}
21 |
22 | );
23 | };
24 |
25 | export default VisuallyHidden;
26 |
--------------------------------------------------------------------------------
/src/data/colors.js:
--------------------------------------------------------------------------------
1 | const colors = {
2 | Green: 'rgb(0, 133, 123)',
3 | Blue: 'rgb(16, 153, 252)',
4 | Purple: 'rgb(113, 107, 241)',
5 | Gold: 'rgb(255, 206, 83)',
6 | Orange: 'rgb(255, 185, 97)',
7 | Red: 'rgb(215, 25, 57)',
8 | Slate: 'rgb(71, 83, 93)',
9 | Midnight: 'rgb(36, 44, 57)',
10 | Grey: 'rgb(129, 162, 178)',
11 | 'Grey-50': 'rgb(129, 162, 178)',
12 | 'Grey-25': 'rgb(129, 162, 178)',
13 | 'Grey-10': 'rgb(129, 162, 178)',
14 | 'Grey-5': 'rgb(129, 162, 178)',
15 | White: 'rgb(255, 255, 255)',
16 | };
17 |
18 | export default colors;
19 |
--------------------------------------------------------------------------------
/src/data/export.js:
--------------------------------------------------------------------------------
1 | const exportSettings = {
2 | '2x - Low': 2,
3 | '4x - Medium': 4,
4 | '6x - High': 6,
5 | };
6 |
7 | export default exportSettings;
8 |
--------------------------------------------------------------------------------
/src/data/presets.js:
--------------------------------------------------------------------------------
1 | const presets = [
2 | {
3 | label: 'Front Left',
4 | modelRotation: { x: -10, y: 0, z: 0 },
5 | cameraRotation: { x: 90, y: 30 },
6 | },
7 | {
8 | label: 'Front',
9 | modelRotation: { x: 0, y: 0, z: 0 },
10 | cameraRotation: { x: 90, y: 0 },
11 | },
12 | {
13 | label: 'Front Right',
14 | modelRotation: { x: -10, y: 0, z: 0 },
15 | cameraRotation: { x: 90, y: -30 },
16 | },
17 | {
18 | label: 'Tilted Left',
19 | modelRotation: { x: -10, y: 0, z: 20 },
20 | cameraRotation: { x: 90, y: -20 },
21 | },
22 | {
23 | label: 'Tilted Right',
24 | modelRotation: { x: -10, y: 0, z: -20 },
25 | cameraRotation: { x: 90, y: 20 },
26 | },
27 | ];
28 |
29 | export default presets;
30 |
--------------------------------------------------------------------------------
/src/hooks/index.js:
--------------------------------------------------------------------------------
1 | import useFormInput from './useFormInput';
2 | import useId from './useId';
3 | import useInterval from './useInterval';
4 | import useInViewport from './useInViewport';
5 | import useLocalStorage from './useLocalStorage';
6 | import useParallax from './useParallax';
7 | import usePrefersColorScheme from './usePrefersColorScheme';
8 | import usePrefersReducedMotion from './usePrefersReducedMotion';
9 | import usePrevious from './usePrevious';
10 | import useRouteTransition from './useRouteTransition';
11 | import useScrollRestore from './useScrollRestore';
12 | import useWindowSize from './useWindowSize';
13 |
14 | export {
15 | useFormInput,
16 | useId,
17 | useInterval,
18 | useInViewport,
19 | useLocalStorage,
20 | useParallax,
21 | usePrefersColorScheme,
22 | usePrefersReducedMotion,
23 | usePrevious,
24 | useRouteTransition,
25 | useScrollRestore,
26 | useWindowSize,
27 | };
28 |
--------------------------------------------------------------------------------
/src/hooks/useFormInput.js:
--------------------------------------------------------------------------------
1 | import { useState, useMemo } from 'react';
2 |
3 | function useFormInput(initialValue = '') {
4 | const [value, setValue] = useState(initialValue);
5 | const [error, setError] = useState();
6 | const [isDirty, setIsDirty] = useState(false);
7 |
8 | useMemo(() => setValue(initialValue), [initialValue]);
9 |
10 | const handleChange = event => {
11 | setValue(event.target.value);
12 | setIsDirty(true);
13 |
14 | // Resolve errors as soon as input becomes valid
15 | if (error && event.target.checkValidity()) {
16 | setError(null);
17 | }
18 | };
19 |
20 | const handleInvalid = event => {
21 | // Prevent native errors appearing
22 | event.preventDefault();
23 | setError(event.target.validationMessage);
24 | };
25 |
26 | const handleBlur = event => {
27 | // Only validate when the user has made a change
28 | if (isDirty) {
29 | event.target.checkValidity();
30 | }
31 | };
32 |
33 | return {
34 | value,
35 | error,
36 | onChange: handleChange,
37 | onBlur: handleBlur,
38 | onInvalid: handleInvalid,
39 | };
40 | }
41 |
42 | export default useFormInput;
43 |
--------------------------------------------------------------------------------
/src/hooks/useId.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | let id = 0;
4 | const genId = () => ++id;
5 |
6 | const useId = () => {
7 | const [id, setId] = useState(null);
8 | useEffect(() => setId(genId()), []);
9 | return id;
10 | };
11 |
12 | export default useId;
13 |
--------------------------------------------------------------------------------
/src/hooks/useInViewport.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | function useInViewport(
4 | elementRef,
5 | unobserveOnIntersect,
6 | options = {},
7 | shouldObserve = true
8 | ) {
9 | const [intersect, setIntersect] = useState(false);
10 | const [isUnobserved, setIsUnobserved] = useState(false);
11 |
12 | useEffect(() => {
13 | if (!elementRef?.current) return;
14 |
15 | const observer = new IntersectionObserver(([entry]) => {
16 | const { isIntersecting, target } = entry;
17 |
18 | setIntersect(isIntersecting);
19 |
20 | if (isIntersecting && unobserveOnIntersect) {
21 | observer.unobserve(target);
22 | setIsUnobserved(true);
23 | }
24 | }, options);
25 |
26 | if (!isUnobserved && shouldObserve) {
27 | observer.observe(elementRef.current);
28 | }
29 |
30 | return () => observer.disconnect();
31 | }, [elementRef, unobserveOnIntersect, options, isUnobserved, shouldObserve]);
32 |
33 | return intersect;
34 | }
35 |
36 | export default useInViewport;
37 |
--------------------------------------------------------------------------------
/src/hooks/useInterval.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | function useInterval(callback, delay, reset) {
4 | const savedCallback = useRef();
5 |
6 | useEffect(() => {
7 | savedCallback.current = callback;
8 | });
9 |
10 | useEffect(() => {
11 | function tick() {
12 | savedCallback.current();
13 | }
14 | if (delay !== null) {
15 | let id = setInterval(tick, delay);
16 | return () => clearInterval(id);
17 | }
18 | }, [delay, reset]);
19 | }
20 |
21 | export default useInterval;
22 |
--------------------------------------------------------------------------------
/src/hooks/useLocalStorage.js:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | function useLocalStorage(key, initialValue) {
4 | const [storedValue, setStoredValue] = useState(() => {
5 | try {
6 | const item = window.localStorage.getItem(key);
7 | return item ? JSON.parse(item) : initialValue;
8 | } catch (error) {
9 | console.error(error);
10 | return initialValue;
11 | }
12 | });
13 |
14 | const setValue = value => {
15 | try {
16 | const valueToStore = value instanceof Function ? value(storedValue) : value;
17 | setStoredValue(valueToStore);
18 | window.localStorage.setItem(key, JSON.stringify(valueToStore));
19 | } catch (error) {
20 | console.error(error);
21 | }
22 | };
23 |
24 | return [storedValue, setValue];
25 | }
26 |
27 | export default useLocalStorage;
28 |
--------------------------------------------------------------------------------
/src/hooks/useParallax.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { usePrefersReducedMotion } from 'hooks';
3 |
4 | function useParallax(multiplier, clamp = true) {
5 | const [offset, setOffset] = useState(0);
6 | const prefersReducedMotion = usePrefersReducedMotion();
7 |
8 | useEffect(() => {
9 | let ticking = false;
10 | let animationFrame = null;
11 |
12 | const animate = () => {
13 | const { innerHeight } = window;
14 | const offsetValue = Math.max(0, window.scrollY) * multiplier;
15 | const clampedOffsetValue = Math.max(
16 | -innerHeight,
17 | Math.min(innerHeight, offsetValue)
18 | );
19 | setOffset(clamp ? clampedOffsetValue : offsetValue);
20 | ticking = false;
21 | };
22 |
23 | const handleScroll = () => {
24 | if (!ticking) {
25 | ticking = true;
26 | animationFrame = requestAnimationFrame(animate);
27 | }
28 | };
29 |
30 | if (!prefersReducedMotion) {
31 | window.addEventListener('scroll', handleScroll);
32 | }
33 |
34 | return () => {
35 | window.removeEventListener('scroll', handleScroll);
36 | cancelAnimationFrame(animationFrame);
37 | };
38 | }, [clamp, multiplier, prefersReducedMotion]);
39 |
40 | return offset;
41 | }
42 |
43 | export default useParallax;
44 |
--------------------------------------------------------------------------------
/src/hooks/usePrefersColorScheme.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | function usePrefersColorScheme() {
4 | const [colorScheme, setColorScheme] = useState(
5 | () => window.matchMedia?.('(prefers-color-scheme: dark)').matches
6 | );
7 |
8 | useEffect(() => {
9 | const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
10 |
11 | const handleMediaChange = () => {
12 | setColorScheme(mediaQuery?.matches);
13 | };
14 |
15 | mediaQuery?.addListener(handleMediaChange);
16 | handleMediaChange();
17 |
18 | return () => {
19 | mediaQuery?.removeListener(handleMediaChange);
20 | };
21 | }, []);
22 |
23 | return colorScheme ? 'dark' : 'light';
24 | }
25 |
26 | export default usePrefersColorScheme;
27 |
--------------------------------------------------------------------------------
/src/hooks/usePrefersReducedMotion.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | function usePrefersReducedMotion() {
4 | const [reduceMotion, setReduceMotion] = useState(
5 | () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
6 | );
7 |
8 | useEffect(() => {
9 | const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
10 |
11 | const handleMediaChange = () => {
12 | setReduceMotion(mediaQuery?.matches);
13 | };
14 |
15 | mediaQuery?.addListener(handleMediaChange);
16 | handleMediaChange();
17 |
18 | return () => {
19 | mediaQuery?.removeListener(handleMediaChange);
20 | };
21 | }, []);
22 |
23 | return reduceMotion;
24 | }
25 |
26 | export default usePrefersReducedMotion;
27 |
--------------------------------------------------------------------------------
/src/hooks/usePrevious.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | function usePrevious(value) {
4 | const ref = useRef();
5 |
6 | useEffect(() => {
7 | ref.current = value;
8 | }, [value]);
9 |
10 | return ref.current;
11 | }
12 |
13 | export default usePrevious;
14 |
--------------------------------------------------------------------------------
/src/hooks/useRouteTransition.js:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import { TransitionContext } from 'app';
3 |
4 | function useRouteTransition() {
5 | return useContext(TransitionContext);
6 | }
7 |
8 | export default useRouteTransition;
9 |
--------------------------------------------------------------------------------
/src/hooks/useScrollRestore.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { usePrevious, usePrefersReducedMotion } from '.';
3 | import { useRouteTransition } from 'hooks';
4 |
5 | function useScrollRestore() {
6 | const { status } = useRouteTransition();
7 | const prevStatus = usePrevious(status);
8 | const prefersReducedMotion = usePrefersReducedMotion();
9 |
10 | useEffect(() => {
11 | const hasEntered = prevStatus === 'entering' && status === 'entered';
12 | const hasEnteredReducedMotion = prefersReducedMotion && status === 'entered';
13 |
14 | if (hasEntered || hasEnteredReducedMotion) {
15 | window.scrollTo(0, 0);
16 | document.getElementById('MainContent').focus();
17 | }
18 | }, [prefersReducedMotion, prevStatus, status]);
19 | }
20 |
21 | export default useScrollRestore;
22 |
--------------------------------------------------------------------------------
/src/hooks/useWindowSize.js:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react';
2 |
3 | function useWindowSize() {
4 | const isClient = typeof window === 'object';
5 | const isIOS = navigator.userAgent.match(/iphone|ipod|ipad/i);
6 | const axis = useRef(() => ({ w: 0, h: 0 }));
7 | const dimensions = useRef(() => Math.abs(window.orientation));
8 |
9 | const createRuler = useCallback(() => {
10 | let ruler = document.createElement('div');
11 |
12 | ruler.style.position = 'fixed';
13 | ruler.style.height = '100vh';
14 | ruler.style.width = 0;
15 | ruler.style.top = 0;
16 |
17 | document.documentElement.appendChild(ruler);
18 |
19 | // Set cache conscientious of device orientation
20 | dimensions.current.w = axis.current === 90 ? ruler.offsetHeight : window.innerWidth;
21 | dimensions.current.h = axis.current === 90 ? window.innerWidth : ruler.offsetHeight;
22 |
23 | // Clean up after ourselves
24 | document.documentElement.removeChild(ruler);
25 | ruler = null;
26 | }, []);
27 |
28 | // Get the actual height on iOS Safari
29 | const getHeight = useCallback(() => {
30 | if (!isClient) return 0;
31 |
32 | if (isIOS) {
33 | createRuler();
34 |
35 | if (Math.abs(window.orientation) !== 90) {
36 | return dimensions.current.h;
37 | }
38 |
39 | return dimensions.current.w;
40 | }
41 |
42 | return window.innerHeight;
43 | }, [createRuler, isClient, isIOS]);
44 |
45 | const getSize = useCallback(() => {
46 | return {
47 | width: isClient ? window.innerWidth : 0,
48 | height: getHeight(),
49 | };
50 | }, [getHeight, isClient]);
51 |
52 | const [windowSize, setWindowSize] = useState(() => getSize());
53 |
54 | useEffect(() => {
55 | const handleResize = () => {
56 | setWindowSize(getSize());
57 | };
58 |
59 | window.addEventListener('resize', handleResize);
60 |
61 | return () => {
62 | window.removeEventListener('resize', handleResize);
63 | };
64 | }, [getSize, isClient]);
65 |
66 | return windowSize;
67 | }
68 |
69 | export default useWindowSize;
70 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { hydrate, render } from 'react-dom';
3 | import App from './app';
4 |
5 | const rootElement = document.getElementById('root');
6 |
7 | if (rootElement.hasChildNodes()) {
8 | hydrate(, rootElement);
9 | } else {
10 | render(, rootElement);
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/404.js:
--------------------------------------------------------------------------------
1 | import { Helmet } from 'react-helmet';
2 | const Page404 = () => (
3 |
4 |
5 | 404 | Device Models
6 |
7 |
8 |
9 | );
10 |
11 | export default Page404;
12 |
--------------------------------------------------------------------------------
/src/pages/Home.js:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react';
2 | import { Helmet } from 'react-helmet';
3 | import Intro from './Intro';
4 | import iphone11 from 'assets/iphone-11.glb';
5 | import iphone12 from 'assets/iphone-12.glb';
6 |
7 | const Home = () => (
8 |
9 |
10 | Device Models
11 |
15 |
16 |
17 |
18 |
23 |
24 | );
25 |
26 | export default Home;
27 |
--------------------------------------------------------------------------------
/src/pages/Intro.css:
--------------------------------------------------------------------------------
1 | .intro {
2 | --rgbText: var(--rgbGray);
3 |
4 | grid-template-columns: var(--maxWidthM) 1fr;
5 | grid-row-gap: var(--spaceL);
6 | display: grid;
7 | align-items: center;
8 | min-height: 100vh;
9 | outline: none;
10 | padding-left: 264px;
11 |
12 | @media (--mediaDesktop) {
13 | & {
14 | grid-template-columns: var(--maxWidthS) 1fr;
15 | padding-left: 250px;
16 | }
17 | }
18 |
19 | @media (--mediaLaptop) {
20 | & {
21 | padding-left: var(--space4XL);
22 | }
23 | }
24 |
25 | @media (--mediaTablet) {
26 | & {
27 | display: flex;
28 | flex-direction: column-reverse;
29 | justify-content: space-between;
30 | padding: var(--spaceOuter);
31 | }
32 | }
33 |
34 | @media (--mediaMobile) {
35 | & {
36 | padding: var(--spaceL);
37 | }
38 | }
39 | }
40 |
41 | .intro--center {
42 | display: flex;
43 | justify-content: center;
44 | padding: 0;
45 | margin: 0 auto;
46 |
47 | & .intro__text {
48 | display: flex;
49 | align-items: center;
50 | flex-direction: column;
51 | width: auto;
52 | }
53 | }
54 |
55 | .intro__text {
56 | --delay: calc(var(--durationM) + var(--durationS));
57 |
58 | position: relative;
59 | width: 100%;
60 | }
61 |
62 | .intro__logo {
63 | margin-bottom: var(--spaceL);
64 | transition-property: opacity;
65 | transition-timing-function: var(--bezierFastoutSlowin);
66 | transition-duration: var(--durationXL);
67 | transition-delay: var(--delay);
68 | opacity: 0;
69 |
70 | @media (--mediaUseMotion) {
71 | & {
72 | transition-property: transform, opacity;
73 | transform: translate3d(0, var(--space3XL), 0);
74 | }
75 | }
76 | }
77 |
78 | .intro__logo--entering,
79 | .intro__logo--entered {
80 | transform: none;
81 | opacity: 1;
82 | }
83 |
84 | .intro__title {
85 | margin-bottom: var(--spaceXL);
86 | transition-property: opacity;
87 | transition-timing-function: var(--bezierFastoutSlowin);
88 | transition-duration: var(--durationXL);
89 | transition-delay: var(--delay);
90 | opacity: 0;
91 |
92 | @media (--mediaUseMotion) {
93 | & {
94 | transition-property: transform, opacity;
95 | transform: translate3d(0, var(--space4XL), 0);
96 | }
97 | }
98 | }
99 |
100 | .intro__title--entering,
101 | .intro__title--entered {
102 | transform: none;
103 | opacity: 1;
104 | }
105 |
106 | .intro__description {
107 | letter-spacing: 1.1px;
108 | margin-bottom: var(--spaceXL);
109 | transition-property: opacity;
110 | transition-timing-function: var(--bezierFastoutSlowin);
111 | transition-duration: var(--durationXL);
112 | transition-delay: var(--delay);
113 | opacity: 0;
114 |
115 | @media (--mediaUseMotion) {
116 | & {
117 | transition-property: transform, opacity;
118 | transform: translate3d(0, var(--space5XL), 0);
119 | }
120 | }
121 | }
122 |
123 | .intro__description--entering,
124 | .intro__description--entered {
125 | transform: none;
126 | opacity: 1;
127 | }
128 |
129 | .intro__content {
130 | transition-property: opacity;
131 | transition-timing-function: var(--bezierFastoutSlowin);
132 | transition-duration: var(--durationXL);
133 | transition-delay: var(--delay);
134 | opacity: 0;
135 |
136 | @media (--mediaUseMotion) {
137 | & {
138 | transition-property: transform, opacity;
139 | transform: translate3d(0, calc(var(--space5XL) + var(--spaceS)), 0);
140 | }
141 | }
142 | }
143 |
144 | .intro__content--entering,
145 | .intro__content--entered {
146 | transform: none;
147 | opacity: 1;
148 | }
149 |
150 | .intro__buttons {
151 | display: grid;
152 | flex-direction: row;
153 | grid-template-columns: auto 1fr;
154 | }
155 |
156 | .intro__socials {
157 | display: flex;
158 | gap: var(--spaceS);
159 | justify-content: flex-end;
160 | }
161 |
162 | .intro__social {
163 | transition-property: opacity;
164 | transition-timing-function: var(--bezierFastoutSlowin);
165 | transition-duration: var(--durationXL);
166 | transition-delay: calc(
167 | var(--delay) + var(--durationS) + (var(--index) * var(--durationXS))
168 | );
169 | opacity: 0;
170 |
171 | @media (--mediaUseMotion) {
172 | & {
173 | transition-property: transform, opacity;
174 | transform: translate3d(0, var(--spaceM), 0);
175 | }
176 | }
177 | }
178 |
179 | .intro__social--entering,
180 | .intro__social--entered {
181 | transform: none;
182 | opacity: 1;
183 | }
184 |
185 | @keyframes grow {
186 | 0% {
187 | transform: scale(0);
188 | }
189 | 100% {
190 | transform: scale(1);
191 | }
192 | }
193 |
194 | .intro__models {
195 | --delay: var(--durationXL);
196 | --backgroundSize: calc(80vh);
197 |
198 | align-items: center;
199 | cursor: grab;
200 | display: flex;
201 | height: 100%;
202 | justify-content: center;
203 | overflow: hidden;
204 | position: relative;
205 | width: 100%;
206 |
207 | &:active {
208 | cursor: grabbing;
209 | }
210 |
211 | &::before {
212 | content: '';
213 | position: relative;
214 | display: block;
215 | width: var(--backgroundSize);
216 | height: var(--backgroundSize);
217 | background: #ffa538;
218 | border-radius: 50%;
219 | }
220 |
221 | @media (--mediaUseMotion) {
222 | &::before {
223 | transform: scale(0);
224 | animation-name: grow;
225 | animation-duration: var(--durationXL);
226 | animation-timing-function: var(--bezierFastoutSlowin);
227 | animation-delay: calc(var(--delay) + var(--durationXS));
228 | animation-fill-mode: forwards;
229 | }
230 | }
231 |
232 | @media (--mediaLaptop) {
233 | & {
234 | --backgroundSize: calc(3 / 5 * 80vw);
235 | }
236 | }
237 |
238 | @media (--mediaTablet) {
239 | & {
240 | --backgroundSize: calc(3 / 5 * 100vw);
241 |
242 | height: calc(3 / 4 * 100vw);
243 | left: auto;
244 | max-height: 100vh;
245 | position: relative;
246 | top: auto;
247 | width: 100%;
248 | }
249 | }
250 |
251 | @media (--mediaMobile) {
252 | & {
253 | --backgroundSize: calc(7 / 12 * 100vw);
254 | }
255 | }
256 | }
257 |
--------------------------------------------------------------------------------
/src/pages/Intro.js:
--------------------------------------------------------------------------------
1 | import { Fragment, Suspense, useEffect } from 'react';
2 | import classNames from 'classnames';
3 | import { sRGBEncoding, Color } from 'three';
4 | import { animated, useSpring } from '@react-spring/three';
5 | import { Transition } from 'react-transition-group';
6 | import { useThree, Canvas } from '@react-three/fiber';
7 | import { useGLTF, useTexture, OrbitControls } from '@react-three/drei';
8 | import Icon from 'components/Icon';
9 | import Heading from 'components/Heading';
10 | import Text from 'components/Text';
11 | import Button from 'components/Button';
12 | import Link from 'components/Link';
13 | import { useWindowSize } from 'hooks';
14 | import { media } from 'utils/style';
15 | import { reflow } from 'utils/transition';
16 | import deviceModels from 'components/Scene/deviceModels';
17 | import prerender from 'utils/prerender';
18 | import socials from './socials';
19 | import './Intro.css';
20 |
21 | const IntroModel = ({ model, position, delay, ...rest }) => {
22 | const { url, texture } = deviceModels[model];
23 | const { gl } = useThree();
24 | const { scene } = useGLTF(url);
25 | const image = useTexture(texture);
26 |
27 | const [x, y, z] = position;
28 | const spring = useSpring({
29 | delay,
30 | config: {
31 | tension: 120,
32 | friction: 26,
33 | },
34 | from: { position: [x, y - 6, z] },
35 | to: { position: [x, y, z] },
36 | });
37 |
38 | useEffect(() => {
39 | scene.traverse(node => {
40 | if (node.name === 'Screen') {
41 | image.encoding = sRGBEncoding;
42 | image.flipY = false;
43 | image.anisotropy = gl.capabilities.getMaxAnisotropy();
44 |
45 | // Decode the texture to prevent jank on first render
46 | gl.initTexture(image);
47 |
48 | node.material.color = new Color(0xffffff);
49 | node.material.transparent = false;
50 | node.material.map = image;
51 | node.material.needsUpdate = true;
52 | }
53 | });
54 | }, [scene, image, gl]);
55 |
56 | return ;
57 | };
58 |
59 | const IntroScene = ({ isMobile }) => {
60 | return (
61 |
62 |
97 |
98 | );
99 | };
100 |
101 | const Intro = ({ center, title, description, buttons, children, models }) => {
102 | const { width } = useWindowSize();
103 | const isMobile = width <= media.tablet;
104 | const size = isMobile ? 48 : 96;
105 |
106 | return (
107 |
108 |
109 | {status => (
110 |
111 |
112 |
118 | {title && (
119 |
124 | {title}
125 |
126 | )}
127 | {description && (
128 |
136 | {description}
137 |
138 | )}
139 |
140 | {buttons && (
141 |
142 |
147 |
148 |
149 |
150 | {socials.map(({ href, icon, label }, index) => (
151 |
161 |
162 |
163 | ))}
164 |
165 |
166 | )}
167 | {children}
168 |
169 |
170 | {!prerender && }
171 |
172 | )}
173 |
174 |
175 | );
176 | };
177 |
178 | export default Intro;
179 |
--------------------------------------------------------------------------------
/src/pages/socials.js:
--------------------------------------------------------------------------------
1 | const socials = [
2 | {
3 | icon: 'figma',
4 | href: 'https://www.figma.com/@codyb',
5 | label: 'Figma',
6 | },
7 | {
8 | icon: 'github',
9 | href: 'https://github.com/CodyJasonBennett/device-models',
10 | label: 'Github',
11 | },
12 | {
13 | icon: 'email',
14 | href: 'https://codyb.co/contact',
15 | label: 'Email',
16 | },
17 | ];
18 |
19 | export default socials;
20 |
--------------------------------------------------------------------------------
/src/plugin/figma.js:
--------------------------------------------------------------------------------
1 | /* globals figma, __html__ */
2 |
3 | figma.showUI(__html__, {
4 | width: 800,
5 | height: 500,
6 | });
7 |
8 | async function sendSelection() {
9 | const [selection] = figma.currentPage.selection;
10 | if (!selection) return figma.ui.postMessage({ type: 'selection', value: null });
11 |
12 | const blob = await selection.exportAsync();
13 |
14 | return figma.ui.postMessage({ type: 'selection', value: blob });
15 | }
16 |
17 | sendSelection();
18 |
19 | figma.on('selectionchange', sendSelection);
20 |
21 | figma.ui.onmessage = async ({ type, name, width, height, blob }) => {
22 | const selection = [];
23 |
24 | switch (type) {
25 | case 'create-empty-frame': {
26 | const frame = figma.createFrame();
27 | frame.name = `${name} Frame`;
28 | frame.resize(width, height);
29 | figma.currentPage.appendChild(frame);
30 |
31 | await figma.loadFontAsync({ family: 'Roboto', style: 'Regular' });
32 | await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
33 |
34 | const text = figma.createText();
35 | text.characters = `${width}x${height}`;
36 | text.fontSize = 48;
37 | text.fontName = { family: 'Inter', style: 'Regular' };
38 | text.fills = [{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }];
39 | text.x = width / 2 - text.width / 2;
40 | text.y = height / 2 - text.height / 2;
41 | frame.appendChild(text);
42 |
43 | selection.push(frame);
44 |
45 | figma.currentPage.selection = selection;
46 | figma.viewport.scrollAndZoomIntoView(selection);
47 |
48 | figma.off('selectionchange', sendSelection);
49 | return figma.closePlugin();
50 | }
51 | case 'save-canvas-image': {
52 | const rectangle = figma.createRectangle();
53 | rectangle.name = `${name} Render`;
54 | rectangle.resize(width, height);
55 |
56 | const image = figma.createImage(blob);
57 | rectangle.fills = [{ type: 'IMAGE', imageHash: image.hash, scaleMode: 'FILL' }];
58 |
59 | figma.currentPage.appendChild(rectangle);
60 |
61 | figma.currentPage.selection = selection;
62 | figma.viewport.scrollAndZoomIntoView(selection);
63 |
64 | figma.off('selectionchange', sendSelection);
65 | return figma.closePlugin();
66 | }
67 | case 'export':
68 | return figma.ui.postMessage({ type: 'save-canvas-image' });
69 | default:
70 | throw new Error();
71 | }
72 | };
73 |
--------------------------------------------------------------------------------
/src/plugin/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: var(--fontStack);
3 | box-sizing: border-box;
4 | font-size: 16px;
5 | margin: 0;
6 | overflow: hidden;
7 | text-align: start;
8 | color: rgba(var(--rgbText));
9 | }
10 |
11 | *,
12 | *::before,
13 | *::after {
14 | box-sizing: inherit;
15 | }
16 |
17 | [data-scroll]::-webkit-scrollbar-track {
18 | background: none;
19 | border-radius: 10px;
20 | }
21 |
22 | [data-scroll]::-webkit-scrollbar-thumb {
23 | border-radius: 10px;
24 | background-color: transparent;
25 | background-clip: padding-box;
26 | border: 4px solid transparent;
27 | }
28 |
29 | [data-scroll]::-webkit-scrollbar {
30 | width: 14px;
31 | height: 14px;
32 | }
33 |
34 | [data-scroll]:hover::-webkit-scrollbar-thumb {
35 | background-color: rgba(var(--rgbText), 0.3);
36 | }
37 |
38 | input,
39 | label,
40 | button {
41 | font-family: inherit;
42 | color: rgba(var(--rgbText));
43 | }
44 |
45 | .ui {
46 | overflow: hidden;
47 | position: absolute;
48 | inset: 0;
49 | outline: none;
50 | background-color: rgba(var(--rgbSurface));
51 | border-radius: 0 0 3px 3px;
52 | }
53 |
54 | .ui__layout {
55 | position: absolute;
56 | inset: 0;
57 | display: grid;
58 | grid-template-columns: 1fr 240px;
59 | grid-template-rows: 100%;
60 | }
61 |
62 | .ui__viewport-wrapper {
63 | display: grid;
64 | grid-template-columns: 100%;
65 | grid-template-rows: 100%;
66 | overflow: hidden;
67 | }
68 |
69 | .ui__viewport {
70 | display: grid;
71 | grid-template-columns: 100%;
72 | grid-template-rows: 100%;
73 | grid-column: 1;
74 | grid-row: 1;
75 | transition-property: transform, opacity;
76 | transition-timing-function: var(--bezierFastoutSlowin);
77 | transition-duration: 0.4s;
78 | opacity: 0;
79 | }
80 |
81 | .ui__viewport--entered {
82 | transform: none;
83 | opacity: 1;
84 | }
85 |
86 | .ui__viewport--entering {
87 | transform: scale(1.3);
88 | }
89 |
90 | .ui__viewport--exiting {
91 | transform: scale(0.7);
92 | }
93 |
94 | .ui__spinner {
95 | position: absolute;
96 | inset: 0 240px 0 0;
97 | display: flex;
98 | align-items: center;
99 | justify-content: center;
100 | background-color: rgba(var(--rgbSurface), 0.6);
101 | transition: opacity 0.4s ease 0s;
102 | }
103 |
104 | .ui__spinner--entering,
105 | .ui__spinner--entered {
106 | opacity: 1;
107 | transition-delay: 0.4s;
108 | }
109 |
110 | .sidebar {
111 | background: rgba(var(--rgbSurface));
112 | border-left: 1px solid rgba(var(--rgbBorder));
113 | padding: 8px 0 0;
114 | display: flex;
115 | flex-direction: column;
116 | }
117 |
118 | .sidebar__label {
119 | font-size: 11px;
120 | font-weight: 600;
121 | color: rgba(var(--rgbText));
122 | padding: 0 8px;
123 | margin-bottom: 6px;
124 | }
125 |
126 | .sidebar__control {
127 | padding: 8px;
128 | display: flex;
129 | flex-direction: column;
130 | }
131 |
132 | .sidebar__control-group {
133 | display: grid;
134 | grid-template-columns: 1fr 1fr 1fr;
135 | grid-gap: 6px;
136 | }
137 |
138 | .sidebar__actions {
139 | margin-top: auto;
140 | display: grid;
141 | grid-auto-flow: row;
142 | grid-gap: 8px;
143 | padding: 16px;
144 | }
145 |
146 | .sidebar__devices {
147 | display: grid;
148 | grid-auto-flow: column;
149 | grid-gap: 8px;
150 | grid-auto-columns: min-content;
151 | padding: 4px 16px 0 16px;
152 | margin: 0 -8px;
153 | overflow-x: auto;
154 | }
155 |
156 | .sidebar__device-button {
157 | width: 64px;
158 | height: 64px;
159 | border-radius: 4px;
160 | background-color: rgba(var(--rgbText), 0.05);
161 | border: 0;
162 | margin: 0;
163 | padding: 4px;
164 | outline: none;
165 | position: relative;
166 | cursor: pointer;
167 | display: flex;
168 | transition: box-shadow 0.3s ease, background 0.3s ease;
169 | }
170 |
171 | .sidebar__device-button:hover {
172 | background-color: rgba(var(--rgbText), 0.1);
173 | }
174 |
175 | .sidebar__device-button:focus {
176 | box-shadow: inset 0 0 0 2px rgba(var(--rgbText), 0.2);
177 | }
178 |
179 | .sidebar__device-button[aria-pressed='true'] {
180 | box-shadow: inset 0 0 0 2px rgba(var(--rgbPrimary));
181 | background-color: rgba(var(--rgbPrimary), 0.2);
182 | }
183 |
184 | .sidebar__device-image {
185 | display: block;
186 | width: 100%;
187 | height: 100%;
188 | flex: 1 1 auto;
189 | }
190 |
191 | .sidebar__export {
192 | display: grid;
193 | grid-template-columns: 1fr auto;
194 | gap: 8px;
195 | }
196 |
--------------------------------------------------------------------------------
/src/plugin/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | useReducer,
4 | useRef,
5 | useState,
6 | Fragment,
7 | useEffect,
8 | Suspense,
9 | useMemo,
10 | useCallback,
11 | } from 'react';
12 | import { render } from 'react-dom';
13 | import classNames from 'classnames';
14 | import ThemeProvider from 'components/ThemeProvider';
15 | import { SwitchTransition, Transition } from 'react-transition-group';
16 | import Tooltip from 'components/Tooltip';
17 | import Spinner from 'components/Spinner';
18 | import Scene from 'components/Scene';
19 | import Dropdown from 'components/Dropdown';
20 | import Input from 'components/Input';
21 | import Button from 'components/Button';
22 | import {
23 | Option,
24 | OptionMenuHeader,
25 | OptionMenuItem,
26 | OptionMenuDivider,
27 | } from 'components/Option';
28 | import { getImage, getImageBlob } from 'utils/image';
29 | import { reflow } from 'utils/transition';
30 | import { initialState, reducer } from 'plugin/reducer';
31 | import deviceModels from 'components/Scene/deviceModels';
32 | import presets from 'data/presets';
33 | import colors from 'data/colors';
34 | import exportSettings from 'data/export';
35 | import './index.css';
36 |
37 | export const PluginContext = createContext({});
38 |
39 | const Preset = ({ label, children, ...rest }) => {
40 | const presetRef = useRef();
41 | const [isHovered, setIsHovered] = useState(false);
42 |
43 | return (
44 |
45 |
56 |
57 | {label}
58 |
59 |
60 | );
61 | };
62 |
63 | const Plugin = () => {
64 | const [state, dispatch] = useReducer(reducer, initialState);
65 | const {
66 | modelId,
67 | presetId,
68 | modelRotation,
69 | cameraRotation,
70 | color,
71 | selection,
72 | requestOutputFrame,
73 | exportQuality,
74 | } = state;
75 |
76 | useEffect(() => {
77 | const { modelRotation, cameraRotation } = presets[presetId];
78 |
79 | dispatch({ type: 'setModelRotation', value: modelRotation });
80 | dispatch({ type: 'setCameraRotation', value: cameraRotation });
81 | }, [presetId]);
82 |
83 | const modelSettings = useMemo(
84 | () => ({
85 | model: modelId,
86 | selection,
87 | color,
88 | modelRotation,
89 | controls: {
90 | cameraRotation,
91 | onUpdate(cameraRotation) {
92 | dispatch({ type: 'setCameraRotation', value: cameraRotation });
93 | },
94 | },
95 | }),
96 | [modelId, selection, color, modelRotation, cameraRotation, dispatch]
97 | );
98 |
99 | useEffect(() => {
100 | window.onmessage = async event => {
101 | const { type, value } = event.data.pluginMessage;
102 |
103 | switch (type) {
104 | case 'selection': {
105 | if (!value) return dispatch({ type: 'setSelection', value: null });
106 |
107 | const blob = new Blob([value], { type: 'image/png' });
108 |
109 | return await Promise.resolve(
110 | new Promise(resolve => {
111 | const reader = new FileReader();
112 | reader.readAsDataURL(blob);
113 | reader.onloadend = () => {
114 | dispatch({ type: 'setSelection', value: reader.result });
115 | resolve(reader.result);
116 | };
117 | })
118 | );
119 | }
120 | case 'save-canvas-image': {
121 | const render = requestOutputFrame(exportQuality);
122 | if (!render) return;
123 |
124 | const { width, height } = await getImage(render);
125 | const blob = getImageBlob(render);
126 |
127 | return parent.postMessage(
128 | {
129 | pluginMessage: {
130 | type: 'save-canvas-image',
131 | name: modelId,
132 | width,
133 | height,
134 | blob,
135 | },
136 | },
137 | '*'
138 | );
139 | }
140 | default:
141 | throw new Error();
142 | }
143 | };
144 | }, [requestOutputFrame, exportQuality, modelId]);
145 |
146 | const createEmptyFrame = useCallback(
147 | event => {
148 | event.preventDefault();
149 |
150 | const { name, width, height } = deviceModels[modelId];
151 |
152 | parent.postMessage(
153 | {
154 | pluginMessage: {
155 | type: 'create-empty-frame',
156 | name,
157 | width,
158 | height,
159 | },
160 | },
161 | '*'
162 | );
163 | },
164 | [modelId]
165 | );
166 |
167 | const saveCanvasImage = event => {
168 | event.preventDefault();
169 |
170 | parent.postMessage({ pluginMessage: { type: 'export' } }, '*');
171 | };
172 |
173 | return (
174 |
175 |
176 |
177 |
178 |
183 |
190 | {status => (
191 |
192 | }>
193 |
194 |
195 |
196 | )}
197 |
198 |
199 |
200 |
201 |
Device Model
202 |
dispatch({ type: 'setModelId', value })}
205 | />
206 |
207 |
208 |
209 | Angle Preset
210 |
211 |
212 | {presets.map(({ label }, index) => (
213 |
{
219 | event.preventDefault();
220 | event.stopPropagation();
221 |
222 | dispatch({ type: 'setPresetId', value: index });
223 | }}
224 | >
225 |
230 |
231 | ))}
232 |
233 |
234 |
280 |
313 |
314 |
315 |
318 |
319 | {false && (
320 |
343 | )}
344 |
349 |
356 | dispatch({ type: 'setColor', value: event.target.value })
357 | }
358 | />
359 |
360 |
361 |
362 |
363 |
364 |
365 |
368 |
369 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 | );
392 | };
393 |
394 | render(, document.getElementById('root'));
395 |
--------------------------------------------------------------------------------
/src/plugin/reducer.js:
--------------------------------------------------------------------------------
1 | export const initialState = {
2 | modelId: 'iPhone 11',
3 | presetId: 0,
4 | modelRotation: { x: 0, y: 0, z: 0 },
5 | cameraRotation: { x: 0, y: 0 },
6 | color: '#FFFFFF',
7 | selection: null,
8 | exportQuality: '4x - Medium',
9 | requestOutputFrame: null,
10 | };
11 |
12 | export function reducer(state, action) {
13 | const { type, value } = action;
14 |
15 | switch (type) {
16 | case 'setModelId':
17 | return { ...state, modelId: value };
18 | case 'setPresetId':
19 | return { ...state, presetId: value };
20 | case 'setModelRotation':
21 | return { ...state, modelRotation: value };
22 | case 'setCameraRotation':
23 | return { ...state, cameraRotation: value };
24 | case 'setColor':
25 | return { ...state, color: value };
26 | case 'setSelection':
27 | return { ...state, selection: value };
28 | case 'setExportQuality':
29 | return { ...state, exportQuality: value };
30 | case 'setRequestOutputFrame':
31 | return { ...state, requestOutputFrame: value };
32 | default:
33 | throw new Error(type);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/utils/focus.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Blur clickable element on mouseup to hide focus states from
3 | * users with pointer devices
4 | */
5 | export function blurOnMouseUp(event) {
6 | event.currentTarget.blur();
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/image.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Asynchronously gets an image by source
3 | */
4 | export function getImage(src) {
5 | return new Promise(resolve => {
6 | const image = new Image();
7 | image.onload = () => resolve(image);
8 | image.src = src;
9 | });
10 | }
11 |
12 | /**
13 | * Converts a URI to a Uint8Array for Figma
14 | */
15 | export function getImageBlob(uri) {
16 | const data = uri.split(',')[1];
17 | const bytes = atob(data);
18 | const buf = new ArrayBuffer(bytes.length);
19 | const array = new Uint8Array(buf);
20 |
21 | for (let i = 0; i < bytes.length; i++) {
22 | array[i] = bytes.charCodeAt(i);
23 | }
24 |
25 | return array;
26 | }
27 |
28 | /**
29 | * Uses the browser's image loading to get the correct image src from a srcSet
30 | */
31 | export async function getImageFromSrcSet({ src, srcSet, sizes }) {
32 | return new Promise((resolve, reject) => {
33 | try {
34 | if (!src && !srcSet) {
35 | throw new Error('No image src or srcSet provided');
36 | }
37 |
38 | const tempImage = new Image();
39 |
40 | if (src) {
41 | tempImage.src = src;
42 | }
43 |
44 | if (srcSet) {
45 | tempImage.srcset = srcSet;
46 | }
47 |
48 | if (sizes) {
49 | tempImage.sizes = sizes;
50 | }
51 |
52 | const onLoad = () => {
53 | tempImage.removeEventListener('load', onLoad);
54 | const source = tempImage.currentSrc;
55 | resolve(source);
56 | };
57 |
58 | tempImage.addEventListener('load', onLoad);
59 | } catch (error) {
60 | reject(`Error loading ${srcSet}: ${error}`);
61 | }
62 | });
63 | }
64 |
65 | /**
66 | * Generates a transparent png of a given width and height
67 | */
68 | export function generateImage(width = 1, height = 1) {
69 | const canvas = document.createElement('canvas');
70 | const ctx = canvas.getContext('2d');
71 |
72 | canvas.width = width;
73 | canvas.height = height;
74 |
75 | ctx.fillStyle = 'rgba(0, 0, 0, 0)';
76 | ctx.fillRect(0, 0, width, height);
77 | const image = canvas.toDataURL('image/png', '');
78 | canvas.remove();
79 | return image;
80 | }
81 |
82 | /**
83 | * Use native image srcSet resolution for video sources
84 | */
85 | export async function resolveVideoSrcFromSrcSet(srcSet) {
86 | const sources = srcSet.split(', ').map(srcString => {
87 | const [src, width] = srcString.split(' ');
88 | const image = generateImage(Number(width.replace('w', '')));
89 | return { src, image, width };
90 | });
91 |
92 | const fakeSrcSet = sources.map(({ image, width }) => `${image} ${width}`).join(', ');
93 | const fakeSrc = await getImageFromSrcSet({ srcSet: fakeSrcSet });
94 |
95 | const videoSrc = sources.find(src => src.image === fakeSrc);
96 | return videoSrc.src;
97 | }
98 |
--------------------------------------------------------------------------------
/src/utils/model.js:
--------------------------------------------------------------------------------
1 | import { Box3, Vector3 } from 'three';
2 |
3 | const MODEL_SIZE = 4;
4 |
5 | const box = new Box3();
6 | const vector = new Vector3();
7 |
8 | /**
9 | * Normalizes a model's geometry to fit a size.
10 | */
11 | export const normalizeModel = (model, size = MODEL_SIZE) => {
12 | // Normalize model dimensions
13 | const [scalar] = box
14 | .setFromObject(model)
15 | .getSize(vector)
16 | .toArray()
17 | .sort((a, b) => b - a);
18 | model.scale.multiplyScalar(size / scalar);
19 |
20 | // Center model based on bounding box
21 | const center = box.setFromObject(model).getCenter(vector);
22 | model.position.copy(center).multiplyScalar(-1);
23 | };
24 |
--------------------------------------------------------------------------------
/src/utils/offset.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Gets the coordinates of an element and applies an offset.
3 | */
4 | function offset(element, orientation = 'bottom') {
5 | if (!element) return;
6 |
7 | const rect = element.getBoundingClientRect();
8 |
9 | const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
10 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
11 |
12 | const offsetTop = (rect.top + scrollTop) * (orientation === 'bottom' ? 1 : -1);
13 | const offsetLeft = rect.left + scrollLeft;
14 |
15 | const top = element.clientHeight + offsetTop + 8;
16 | const left = offsetLeft + element.clientWidth / 2;
17 |
18 | return { top, left };
19 | }
20 |
21 | export default offset;
22 |
--------------------------------------------------------------------------------
/src/utils/prerender.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns true if being prerendered by react-snap. Useful for stuff
3 | * that needs to only run client-side and not during prerendering
4 | */
5 | const prerender = navigator.userAgent === 'ReactSnap';
6 | export default prerender;
7 |
--------------------------------------------------------------------------------
/src/utils/style.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Media query breakpoints
3 | */
4 | export const media = {
5 | desktop: 2080,
6 | laptop: 1680,
7 | tablet: 1024,
8 | mobile: 696,
9 | mobileS: 400,
10 | };
11 |
12 | /**
13 | * Convert a px string to a number
14 | */
15 | export const pxToNum = px => Number(px.replace('px', ''));
16 |
17 | /**
18 | * Convert a number to a px string
19 | */
20 | export const numToPx = num => `${num}px`;
21 |
22 | /**
23 | * Convert pixel values to rem for a11y
24 | */
25 | export const pxToRem = px => `${px / 16}rem`;
26 |
27 | /**
28 | * Convert ms token values to a raw numbers for ReactTransitionGroup
29 | * Transition delay props
30 | */
31 | export const msToNum = msString => Number(msString.replace('ms', ''));
32 |
33 | /**
34 | * Convert a number to an ms string
35 | */
36 | export const numToMs = num => `${num}ms`;
37 |
38 | /**
39 | * Convert an rgb theme property (e.g. rgbBlack: '0 0 0')
40 | * to values that can be spread into a ThreeJS Color class
41 | */
42 | export const rgbToThreeColor = rgb => rgb.split(', ').map(value => Number(value) / 255);
43 |
--------------------------------------------------------------------------------
/src/utils/transition.js:
--------------------------------------------------------------------------------
1 | const visibleStatus = ['entering', 'entered'];
2 |
3 | /**
4 | * Is the given TransitionStatus visible?
5 | */
6 | export const isVisible = status => visibleStatus.includes(status);
7 |
8 | /**
9 | * Is the given TransitionStatus hidden?
10 | */
11 | export const isHidden = status => !visibleStatus.includes(status);
12 |
13 | /**
14 | * Forces a reflow to trigger transitions on enter
15 | */
16 | export const reflow = node => node && node.offsetHeight;
17 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "cleanUrls": true,
3 | "github": {
4 | "silent": true
5 | },
6 | "rewrites": [{ "source": "/index.html", "destination": "/" }],
7 | "headers": [
8 | {
9 | "source": "/(.*)",
10 | "headers": [
11 | {
12 | "key": "Cache-Control",
13 | "value": "public, max-age=0"
14 | },
15 | {
16 | "key": "Strict-Transport-Security",
17 | "value": "max-age=63072000; includeSubDomains; preload"
18 | },
19 | {
20 | "key": "X-XSS-Protection",
21 | "value": "1; mode=block"
22 | },
23 | {
24 | "key": "X-Content-Type-Options",
25 | "value": "nosniff"
26 | },
27 | {
28 | "key": "X-Frame-Options",
29 | "value": "SAMEORIGIN"
30 | },
31 | { "key": "Referrer-Policy", "value": "no-referrer-when-downgrade" },
32 | {
33 | "key": "Content-Security-Policy",
34 | "value": "upgrade-insecure-requests;"
35 | }
36 | ]
37 | },
38 | {
39 | "source": "/(.*).(js|css|woff2|jpe?g|png|svg|glb|gltf|mp4)",
40 | "headers": [
41 | {
42 | "key": "Cache-Control",
43 | "value": "public, max-age=31536000"
44 | }
45 | ]
46 | }
47 | ]
48 | }
49 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 | const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
4 | const postcssOptions = require('./postcss.config');
5 |
6 | module.exports = () => ({
7 | devtool: process.env.NODE_ENV === 'development' && 'inline-source-map',
8 |
9 | module: {
10 | rules: [
11 | {
12 | test: /\.js$/,
13 | exclude: /node_modules/,
14 | use: 'babel-loader',
15 | },
16 | {
17 | test: /\.css$/,
18 | use: [
19 | 'style-loader',
20 | 'css-loader',
21 | {
22 | loader: 'postcss-loader',
23 | options: { postcssOptions },
24 | },
25 | ],
26 | },
27 | {
28 | test: /\.(png|jpe?g|woff2|glb|gltf)$/,
29 | use: 'url-loader',
30 | },
31 | ],
32 | },
33 | entry: {
34 | index: './src/plugin/index.js',
35 | figma: './src/plugin/figma.js',
36 | },
37 | resolve: {
38 | extensions: ['.js'],
39 | modules: [path.resolve(__dirname, 'src'), 'node_modules'],
40 | },
41 | output: {
42 | publicPath: '/',
43 | filename: '[name].js',
44 | path: path.resolve(__dirname, 'build-plugin'),
45 | },
46 | plugins: [
47 | new HtmlWebpackPlugin({
48 | template: './public/index.html',
49 | filename: 'index.html',
50 | inlineSource: '.(js)$',
51 | inject: 'body',
52 | chunks: ['index'],
53 | cache: false,
54 | }),
55 | new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin),
56 | ],
57 | });
58 |
--------------------------------------------------------------------------------