Repository: sambernhardt/ipad-cursor
Branch: master
Commit: 6ac8a16b2f9c
Files: 24
Total size: 21.9 KB
Directory structure:
gitextract_fk40wx2f/
├── .gitignore
├── LICENSE
├── README.md
├── components/
│ ├── GoogleAnalytics/
│ │ ├── init.js
│ │ └── layout.js
│ ├── Providers.js
│ └── components/
│ ├── Footer.js
│ ├── Header.js
│ ├── Hero.js
│ ├── NavLink.js
│ ├── Text/
│ │ └── index.js
│ ├── TextArea.js
│ └── Toggle.js
├── cursor/
│ ├── Context.js
│ ├── Cursor.js
│ ├── Provider.js
│ ├── WithHover.js
│ └── utils.js
├── package.json
├── pages/
│ ├── _app.js
│ ├── _document.js
│ ├── index.js
│ └── test.js
└── theme.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
/node_modules
/.next
next.config.js
.now
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2020 sambernhardt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
`git clone https://github.com/sambernhardt/ipad-cursor.git`
`npm i`
`npm run start`

# Basic usage
## Add the CursorProvider to a page
```javascript
// app.js
import App from 'next/app';
import CursorProvider from '../cursor/Provider';
export default class MyApp extends App {
render () {
const { Component, pageProps } = this.props;
return (
)
}
}
```
## Then wrap your components with the `WithHover` function
```javascript
// Component.js
import WithHover from '../cursor/WithHover';
const Component = () =>
;
export default WithHover( , 'block');
```
### Caveats:
- To move the contents of the hovered component, the component must have a display type of `inline-block` or `block`. CSS transforms don't work on inline elements.
================================================
FILE: components/GoogleAnalytics/init.js
================================================
import ReactGA from "react-ga"
export const initGA = () => {
ReactGA.initialize(process.env.google_analytics)
}
export const logPageView = () => {
ReactGA.set({ page: window.location.pathname })
ReactGA.pageview(window.location.pathname)
}
================================================
FILE: components/GoogleAnalytics/layout.js
================================================
import React, { Component } from "react"
import { initGA, logPageView } from "./init.js"
export default class Layout extends Component {
componentDidMount () {
if (!window.GA_INITIALIZED) {
initGA()
window.GA_INITIALIZED = true
}
logPageView()
}
render () {
return (
{this.props.children}
)
}
}
================================================
FILE: components/Providers.js
================================================
import { Fragment, useState, useEffect } from 'react';
import { createGlobalStyle, ThemeProvider } from 'styled-components';
import { Reset } from 'styled-reset';
import useDarkMode from 'use-dark-mode';
import {light, dark} from '../theme';
import CursorProvider from '../cursor/Provider';
export default ({children}) => {
const [mounted, setMounted] = useState(false);
const {value} = useDarkMode(false, { storageKey: null });
useEffect(() => {
setMounted(true)
}, []);
const body =
{children}
;
if (!mounted) {
return {body}
}
return body;
}
const GlobalStyle = createGlobalStyle`
body {
color: ${({theme}) => theme.colors.body};
background: ${({theme}) => theme.colors.background};
font-family: ${({theme}) => theme.fonts.default};
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: ${({theme}) => theme.fontSizes[3]}px;
margin-bottom: ${({theme}) => theme.space[3]}px;
}
`;
================================================
FILE: components/components/Footer.js
================================================
import styled from 'styled-components';
import WithHover from '../../cursor/WithHover';
import Toggle from './Toggle';
const Container = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const Link = WithHover(styled.a`
color: ${({theme}) => theme.colors.blue};
text-decoration: none;
z-index: 99;
padding: 8px 2px;
&:hover {
color: ${({theme}) => theme.colors.body};
}
`, 'block');
const Heading = styled.div`
font-size: 32px;
`;
export default () => {
return (
Find me on the tweets or check out the code.
)
}
================================================
FILE: components/components/Header.js
================================================
import styled from 'styled-components';
import NavLink from './NavLink';
const Container = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const Title = styled.h2`
font-weight: 600;
`;
const Links = styled.div`
display: flex;
font-weight: 600;
`;
const Header = () => {
return (
iPad Cursor
Button 1
Button 2
Button 3
)
}
export default Header;
================================================
FILE: components/components/Hero.js
================================================
import styled from 'styled-components';
import TextArea from './TextArea';
const Container = styled.div`
min-height: 70vh;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
margin-bottom: 24px;
`;
const Hero = () => {
return (
)
}
export default Hero;
================================================
FILE: components/components/NavLink.js
================================================
import { useState, useContext } from 'react';
import styled from 'styled-components';
import WithHover from '../../cursor/WithHover';
const Container = styled.div`
padding: 8px 16px;
position: relative;
`;
const NavLink = (props) => {
return (
)
}
export default WithHover(NavLink, 'block');
================================================
FILE: components/components/Text/index.js
================================================
import styled from 'styled-components';
const Subheader = styled.h2`
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
`;
module.exports = {
Subheader
}
================================================
FILE: components/components/TextArea.js
================================================
import { useState, useEffect, useCallback, useContext } from 'react';
import { transparentize } from 'polished';
import styled from 'styled-components';
import autosize from 'autosize';
import WithHover from '../../cursor/WithHover';
const Container = WithHover(styled.textarea`
font-size: 32px;
width: 100%;
background: transparent;
color: ${({ theme }) => theme.colors.body};
font-family: ${({theme}) => theme.fonts.default};
border: none;
margin-bottom: 24px;
resize: none;
&:focus {
outline: none;
}
&::selection {
background: ${({theme}) => transparentize(.6, theme.colors.highlight)};
}
`, 'text');
const TextArea = (props) => {
const [ myRef, setRef ] = useState();
const ref = useCallback(node => {
autosize(node)
setRef(node)
});
useEffect(() => {
if (myRef && props.focus) {
myRef.focus();
}
},[myRef])
return (
)
}
export default TextArea;
================================================
FILE: components/components/Toggle.js
================================================
import { useContext } from 'react';
import styled from 'styled-components';
import { transparentize } from 'polished';
import WithHover from '../../cursor/WithHover';
import CursorContext from '../../cursor/Context';
const height = 20;
const Container = WithHover(styled.div`
display: inline-flex;
padding: 4px;
position: relative;
align-items: center;
span {
font-size: 13px;
font-weight: 600;
}
`, 'block');
const Dot = styled.div`
background: white;
box-shadow: 0 0 10px rgba(0,0,0,.2);
height: ${height - 4}px;
width: ${height - 4}px;
border-radius: ${(height - 4) / 2}px;
position: absolute;
left: 2px;
top: 2px;
transition: left .3s cubic-bezier(0.075, 0.82, 0.165, 1);
`;
const ToggleContainer = styled.div`
position: relative;
height: ${height}px;
width: ${height * 2}px;
border-radius: ${height / 2}px;
background: ${({theme}) => transparentize(.7, theme.colors.body)};
margin-right: 16px;
overflow: hidden;
${({showingCursor, theme}) => showingCursor && `
background: ${theme.colors.green};
${Dot} {
left: ${height + 2}px;
}
`}
`;
export default ({}) => {
const {toggleCursor, showingCursor} = useContext(CursorContext);
const handleClick = () => {
toggleCursor();
}
return (
Show cursor
)
};
================================================
FILE: cursor/Context.js
================================================
import { createContext } from 'react';
export default createContext("light");
================================================
FILE: cursor/Cursor.js
================================================
import { useContext, useState, useRef, useEffect } from 'react';
import styled from 'styled-components';
import { transparentize } from 'polished';
import { gsap } from 'gsap';
import CursorContext from './Context';
import { getRelativePosition } from './utils';
const Debug = styled.div`
background: green;
width: 100vw;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
padding: 8px 16px;
> * {
min-width: 200px;
}
`;
const Cursor = styled.div`
width: 24px;
height: 24px;
position: absolute;
background: ${({theme}) => transparentize(.5, theme.colors.cursor)};
border-radius: 12px;
z-index: -1;
transition: opacity .3s;
&.block {
border-radius: 4px;
}
&.text {
height: 30px;
width: 3px;
border-radius: 1px;
}
&.pressing {
opacity: .5;
transition: opacity 0s;
}
`;
const CursorContainer = ({ debug }) => {
const {
pos,
selectedElement,
status,
pressing,
setStatus
} = useContext(CursorContext);
const cursorRef = useRef();
let baseStyles = {
left: pos.x - 12,
top: pos.y - 12,
width: '24px',
height: '24px',
};
// when the selectedElement or status changes
useEffect(() => {
if (!selectedElement.el) return;
if (status == "entering" || status == "shifting") {
// console.log(selectedElement)
if (selectedElement.type == "block") {
gsap.to(cursorRef.current, {
duration: .5,
ease: "elastic.out(1, 1)",
left: selectedElement.el.offsetLeft,
top: selectedElement.el.offsetTop,
height: selectedElement.el.offsetHeight + "px",
width: selectedElement.el.offsetWidth + "px",
borderRadius: '4px',
onComplete: () => {
setStatus("entered");
}
});
}
} else if (status == "exiting") {
// kill all current animations for the block and clear the props it has added
gsap.killTweensOf(cursorRef.current);
}
}, [selectedElement, status]);
useEffect(() => {
// general exit handling
if (status == "exiting" && !selectedElement.el) {
gsap.killTweensOf(cursorRef.current);
gsap.to(cursorRef.current, {
duration: .5,
ease: "elastic.out(1, .5)",
width: '24px',
height: '24px',
x: 0,
y: 0,
left: pos.x - 12,
top: pos.y - 12,
borderRadius: '12px',
onComplete: () => {
setStatus("");
},
});
} else if ((status == "entering" || status == "shifting") && selectedElement.type == "text") {
// text cursor handling
const { textSize } = selectedElement.config;
gsap.killTweensOf(cursorRef.current);
gsap.to(cursorRef.current, {
duration: .5,
ease: "elastic.out(1, 1)",
height: textSize,
width: "3px",
x: 12,
y: (textSize / -2) + 10,
borderRadius: '1px',
onComplete: () => {
setStatus("entered");
}
});
}
}, [pos]);
if (selectedElement.el) {
const amount = 5;
const relativePos = getRelativePosition(pos, selectedElement.el);
const xMid = selectedElement.el.clientWidth / 2;
const yMid = selectedElement.el.clientHeight / 2;
const xMove = (relativePos.x - xMid) / selectedElement.el.clientWidth * amount;
const yMove = (relativePos.y - yMid) / selectedElement.el.clientHeight * amount;
if (selectedElement.type == "block") {
baseStyles = {
left: selectedElement.el.offsetLeft + xMove,
top: selectedElement.el.offsetTop + yMove,
height: selectedElement.el.offsetHeight + "px",
width: selectedElement.el.offsetWidth + "px",
}
}
}
let shapeClass = selectedElement.el && !(status == "entering" || status == "shifting") && selectedElement.type;
return (
{debug &&
{JSON.stringify({pos})}
{JSON.stringify(selectedElement.type)}
{JSON.stringify({status})}
}
)
}
export default CursorContainer;
================================================
FILE: cursor/Provider.js
================================================
import { useState } from 'react';
import { createGlobalStyle } from 'styled-components';
import Cursor from './Cursor';
import Context from './Context';
const GlobalStyle = createGlobalStyle`
body, input, textarea, a {
${({ showingCursor }) => !showingCursor && `
cursor: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjbQg61aAAAADUlEQVQYV2P4//8/IwAI/QL/+TZZdwAAAABJRU5ErkJggg=='),
url(cursor.png),
none;
`}
}
`;
const Provider = ({debug, children}) => {
const [ mousePos, setMousePos ] = useState({ x: 0, y: 0 });
const [ selectedElement, selectedElementSet ] = useState({ el: null });
const [ status, setStatus ] = useState("");
const [ pressing, setPressing ] = useState(false);
const [ showingCursor, showingCursorSet ] = useState(false);
const handleMouseMove = ({ pageX, pageY }) => {
setMousePos({x: pageX, y: pageY})
};
const context = {
pos: mousePos,
selectedElementSet: (element) => {
selectedElementSet(element)
if (!selectedElement.el) {
setStatus("entering")
} else {
setStatus("shifting")
}
},
removeSelectedElement: () => {
setStatus("exiting")
selectedElementSet({ el: null })
},
setStatus: setStatus,
status: status,
selectedElement,
pressing,
toggleCursor: () => showingCursorSet(!showingCursor),
showingCursor: showingCursor
};
return (
setPressing(true)}
onMouseUp={() => setPressing(false)}
>
{children}
)
};
export default Provider;
================================================
FILE: cursor/WithHover.js
================================================
import { useState, useContext } from 'react';
import CursorContext from './Context';
import { getRelativePosition } from './utils';
export default (Component, type, config) => ({passThroughRef, ...props}) => {
const context = useContext(CursorContext);
const { selectedElement, pos } = context;
const [ hovering, setHovering ] = useState(false);
const handleMouseEnter = e => {
if (!context.selectedElementSet) return;
let result = {
el: e.currentTarget,
type,
config: {...config}
}
if (type == "text") {
let computed = window.getComputedStyle(e.currentTarget).fontSize;
result.config.textSize = parseFloat(computed.replace("px"));
}
context.selectedElementSet(result);
setHovering(true);
}
const handleMouseLeave = ({pageX, pageY, ...e}) => {
if (!context.removeSelectedElement) return;
context.removeSelectedElement()
setHovering(false);
}
let styles;
if (hovering && selectedElement.el && selectedElement.type == "block") {
const amount = selectedElement.config.hoverOffset ? selectedElement.config.hoverOffset : 2;
const relativePos = getRelativePosition(pos, selectedElement.el);
const xMid = selectedElement.el.offsetWidth / 2;
const yMid = selectedElement.el.offsetHeight / 2;
const xMove = (relativePos.x - xMid) / selectedElement.el.offsetHeight * amount;
const yMove = (relativePos.y - yMid) / selectedElement.el.offsetHeight * amount;
styles = {
transform: `translate(${xMove}px, ${yMove}px)`,
}
}
return
}
================================================
FILE: cursor/utils.js
================================================
const getRelativePosition = (pageCoords, element) => {
return {
x: pageCoords.x - element.offsetLeft,
y: pageCoords.y - element.offsetTop
}
}
module.exports = {
getRelativePosition
}
================================================
FILE: package.json
================================================
{
"name": "ipad-cursor",
"version": "0.0.1",
"description": "A web implementation of the new iPadOS cursor in Next.js",
"main": "index.js",
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
"keywords": [
"next.js",
"next",
"styled-components",
"theme"
],
"author": "sdbernhardt@gmail.com",
"license": "ISC",
"dependencies": {
"autosize": "^4.0.2",
"gsap": "^3.2.6",
"isomorphic-unfetch": "^3.0.0",
"next": "^9.3.2",
"polished": "^3.6.0",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-ga": "^2.7.0",
"styled-components": "^4.3.2",
"styled-reset": "^4.0.0",
"use-dark-mode": "^2.3.1"
},
"devDependencies": {
"babel-plugin-styled-components": "^1.10.6"
}
}
================================================
FILE: pages/_app.js
================================================
import React from 'react';
import App from 'next/app';
import Providers from '../components/Providers.js';
export default class MyApp extends App {
render () {
const { Component, pageProps } = this.props;
return (
)
}
}
================================================
FILE: pages/_document.js
================================================
import Document from 'next/document'
import { ServerStyleSheet } from 'styled-components'
export default class MyDocument extends Document {
static async getInitialProps (ctx) {
const sheet = new ServerStyleSheet()
const originalRenderPage = ctx.renderPage
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: App => props => sheet.collectStyles( )
})
const initialProps = await Document.getInitialProps(ctx)
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
>
)
}
} finally {
sheet.seal()
}
}
}
================================================
FILE: pages/index.js
================================================
import styled from 'styled-components';
import Header from '../components/components/Header';
import Hero from '../components/components/Hero';
import Footer from '../components/components/Footer';
import GoogleAnalytics from "../components/GoogleAnalytics/layout.js"
const Main = styled.div`
width: 100%;
max-width: 900px;
margin: 0 auto;
padding: 48px 24px;
box-sizing: border-box;
`;
const Home = () => {
const body = (
);
return process.env.google_analytics ? {body} : body
};
export default Home;
================================================
FILE: pages/test.js
================================================
import styled from 'styled-components';
import WithHover from '../cursor/WithHover';
const Link = WithHover(styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 350px;
height: 350px;
border-radius: 8px;
font-size: 24px;
transition-duration: .2s;
&:hover {
transition-duration: 0s;
}
`, 'block', {
hoverOffset: 20
});
const Main = styled.div`
width: 100%;
max-width: 900px;
margin: 0 auto;
padding: 48px 24px;
box-sizing: border-box;
`;
export default () => (
Test
)
================================================
FILE: theme.js
================================================
const light = {
colors: {
background: '#fff',
foreground: '#eee',
cursor: '#888',
body: '#222',
black: '#222',
purple: '#11144C',
red: '#E16262',
green: 'rgb(52,199,89)',
yellow: '#FABC60',
blue: 'rgb(0,122,255)',
highlight: '#FABC60',
}
}
const dark = {
colors: {
body: '#fff',
background: 'black',
cursor: '#bbb',
foreground: '#222',
purple: '#11144C',
red: '#E16262',
green: 'rgb(48,209,88)',
yellow: '#FABC60',
blue: 'rgb(10,132,255)',
highlight: 'rgb(10,132,255)',
}
}
const common = {
breakpoints: ['40em', '52em', '64em'],
fonts: {
default: '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;'
},
fontSizes: [16, 20, 24, 32, 48, 64],
space: [
0, 4, 8, 16, 32, 64, 128, 256
]
}
module.exports = {
light: {...common, ...light},
dark: {...common, ...dark}
}