Skip to content

Instantly share code, notes, and snippets.

@dragon-fish
Last active April 13, 2023 06:52
Show Gist options
  • Save dragon-fish/916c5a0f5988dfbb185a3c5a40719fe6 to your computer and use it in GitHub Desktop.
Save dragon-fish/916c5a0f5988dfbb185a3c5a40719fe6 to your computer and use it in GitHub Desktop.
An iPadOS cursor simulation for the browser
/**
* @author dragon-fish <dragon-fish@qq.com>
* @lisence MIT
* @refered https://github.com/hongkiulam/ipad-cursor-js by @hongkiulam
*/
const TEXT_ELEMENT_TAGS = ['P', 'SPAN', 'H1', 'H2', 'H3', 'H4', 'TEXTAREA']
export class IpadCursor {
public cursor = document.createElement('div')
private nativeCursorStyles = document.createElement('style')
private states = {
// width and height for general cursor
baseCursorWidth: '1rem',
baseCursorHeight: '1rem',
// current width and height and borderRadius, see `onCursorMove`
cursorWidth: '1rem',
cursorHeight: '1rem',
borderRadius: '0px',
// current mouse position on page
mouseX: 0,
mouseY: 0,
hoveredElement: null as HTMLElement | null,
// when true, cursor will stop following mouse position
isCursorLocked: false,
}
constructor(public options?: any) {}
load() {
// start animation
requestAnimationFrame(this.onCursorMove.bind(this))
// dont want on mobile
if ('ontouchstart' in window) {
return
}
// create cursor
document.body.appendChild(this.cursor)
this.cursor.id = 'cursor'
this.states.cursorWidth = this.states.baseCursorWidth
this.states.cursorHeight = this.states.baseCursorHeight
this.states.borderRadius = `calc(${this.states.baseCursorWidth} / 2)`
// disable default cursor, add base cursor styles
this.nativeCursorStyles.id = 'native-cursor-styles'
document.head.appendChild(this.nativeCursorStyles)
this.nativeCursorStyles.innerText = '*{cursor:none}'
// apply styles to cursor
const customCursorStyles = document.createElement('style')
document.head.appendChild(customCursorStyles)
customCursorStyles.innerText = `
#cursor {
position:fixed;
top:0;
left:0;
width: ${this.states.cursorWidth};
height: ${this.states.cursorHeight};
background:${this.cursor.dataset.bg || 'gray'};
opacity: 0.5;
border-radius: 50%;
display: none;
pointer-events:none;
transition-timing-function: ease;
transition: ${this.transition({
width: 0.15,
height: 0.15,
opacity: 0.15,
transform: 0.07,
})};
margin: 0px !important;
padding: 0px !important;
z-index: 99999999;
}`
// keep track of current mouse position on page
document.addEventListener('mousemove', (e) => {
this.states.mouseX = e.clientX
this.states.mouseY = e.clientY
})
}
// main cursor logic here
onCursorMove() {
const nextFrame = () =>
requestAnimationFrame(() => {
this.onCursorMove()
})
if (!this.cursor) return nextFrame()
// mouse hasn't moved yet
if (this.states.mouseX === undefined || this.states.mouseY === undefined) return nextFrame()
// on every frame, we update the cursor based on our app state
this.cursor.style.width = this.states.cursorWidth
this.cursor.style.height = this.states.cursorHeight
this.cursor.style.borderRadius = this.states.borderRadius
this.cursor.style.display = 'block'
if (!this.states.isCursorLocked) {
this.cursor.style.transform = `translate(calc(${this.states.mouseX}px - (${this.states.cursorWidth} / 2)), calc(${this.states.mouseY}px - (${this.states.cursorHeight} / 2))) `
}
// (because of drag cursor) remove lingering transform in case the element is no longer hovered
if (this.states.hoveredElement) {
this.states.hoveredElement.style.removeProperty('transform')
}
// grab the element under cursor, usually the most nested element
this.states.hoveredElement = document.elementFromPoint(this.states.mouseX, this.states.mouseY) as HTMLElement
// cursor probably left browser and we received negative coords
if (!this.states.hoveredElement) return nextFrame()
// check if any parent element has the data-cursor attribute as that will take priority
// added this to prevent text based elements taking priority
/**
* e.g. div[data-cursor='fill'] > h1 > span
* It's likely the effect wants to be applied to the div here but the
* span would be picked up by elementFromPoint
*/
const elementsFromPoint = document.elementsFromPoint(this.states.mouseX, this.states.mouseY) as HTMLElement[]
if (elementsFromPoint) {
for (const el of elementsFromPoint) {
// if anything we are hovering over has data-cursor then we set that as the hovered element
if (el.dataset['cursor']) {
this.states.hoveredElement = el
break
}
}
}
if (this.states.hoveredElement.dataset['cursor'] === 'reset') {
this.states.isCursorLocked = false
this.useNativeCursor()
return nextFrame()
}
// handle fill cursor
if (this.states.hoveredElement.dataset['cursor'] === 'fill') {
this.states.isCursorLocked = true
this.useFillCursor()
return nextFrame()
}
// handle drag cursor
if (this.states.hoveredElement.dataset['cursor'] === 'drag') {
this.states.isCursorLocked = true
this.useDragCursor()
return nextFrame()
}
// handle text cursor
if (TEXT_ELEMENT_TAGS.includes(this.states.hoveredElement.tagName)) {
this.states.isCursorLocked = false
this.useTextCursor()
return nextFrame()
}
// handle inputs
if (this.states.hoveredElement.tagName === 'INPUT') {
this.states.isCursorLocked = false
this.resetCursor()
this.hideNativeCursor()
const textInputs = ['text', 'email', 'number', 'password', 'search', 'tel', 'url', null]
if (textInputs.includes(this.states.hoveredElement.getAttribute('type'))) {
this.useTextCursor()
} else {
this.useGeneralCursor()
}
return nextFrame()
}
// use general cursor
this.states.isCursorLocked = false
this.useGeneralCursor()
nextFrame()
}
resetCursor() {
// removes all possible overrides, results in the defaults applied by css stylesheet
this.cursor.style.removeProperty('z-index')
this.cursor.style.removeProperty('opacity')
this.cursor.style.removeProperty('transition')
}
hideNativeCursor() {
if (this.nativeCursorStyles.innerText !== '*{cursor:none}') {
this.nativeCursorStyles.innerText = `*{cursor:none}`
}
}
useNativeCursor() {
this.cursor.style.setProperty('opacity', '0')
this.nativeCursorStyles.innerText = ``
}
useGeneralCursor() {
this.resetCursor()
this.hideNativeCursor()
this.states.cursorWidth = this.states.baseCursorWidth
this.states.cursorHeight = this.states.baseCursorHeight
this.states.borderRadius = `calc(${this.states.baseCursorWidth} / 2)`
}
useFillCursor() {
this.hideNativeCursor()
const { width: w, height: h, x, y } = this.states.hoveredElement?.getBoundingClientRect()!
const offsetW = w / 30
const offsetH = h / 30
// expand to containers size + offset
this.states.cursorWidth = `${w + offsetW}px`
this.states.cursorHeight = `${h + offsetH}px`
this.states.borderRadius = '3px'
// slow down transition
this.cursor.style.transition = this.transition({
width: 0.2,
height: 0.2,
opacity: 0.15,
transform: 0.15,
})
// make cursor go below hovered element
this.cursor.style.zIndex = '-1'
this.cursor.style.opacity = '0.3'
const alignCentreCoords = {
x: x - offsetW / 2,
y: y - offsetH / 2,
}
const cursorParallax = this.parallaxShiftAmount(this.states.hoveredElement!, offsetW, offsetH)
const hoveredElementParallax = this.parallaxShiftAmount(this.states.hoveredElement!, offsetW / 2, offsetH / 2)
const resultantCoords = {
x: alignCentreCoords.x + cursorParallax.x,
y: alignCentreCoords.y + cursorParallax.y,
}
this.cursor.style.transform = `translate(${resultantCoords.x}px, ${resultantCoords.y}px)`
this.states.hoveredElement!.style.transform = `translate(${hoveredElementParallax.x}px,${hoveredElementParallax.y}px)`
}
useDragCursor() {
this.hideNativeCursor()
// shrink cursor, then hide with opacity
const { width, height } = this.states.hoveredElement!.getBoundingClientRect()
this.states.cursorWidth = '5px'
this.states.cursorHeight = '5px'
this.cursor.style.opacity = '0'
this.cursor.style.transition = 'initial'
const { x, y } = this.parallaxShiftAmount(this.states.hoveredElement!, width / 20, height / 20)
this.states.hoveredElement!.style.transform = `translate(${x}px,${y}px)`
}
useTextCursor() {
this.resetCursor()
this.hideNativeCursor()
const fontSize = window.getComputedStyle(this.states.hoveredElement!).getPropertyValue('font-size')
this.states.cursorWidth = '1px'
this.states.cursorHeight = fontSize
}
/**
* @param {HTMLElement} elementToGetDimensionsFrom
* @param {number} offset
*/
parallaxShiftAmount(elementToGetDimensionsFrom: HTMLElement, offsetW: number, offsetH: number) {
// calculates mouse distance from the centre of the element
// then calculates how many pixels to shift an element for every pixel moved by mouse
// where offset is the total distance moveable
const { width: w, height: h, x, y } = elementToGetDimensionsFrom.getBoundingClientRect()
const elementCentre = { x: x + w / 2, y: y + h / 2 }
const mouseDistanceFromCentre = {
x: this.states.mouseX - elementCentre.x,
y: this.states.mouseY - elementCentre.y,
}
const parallaxFactor = {
x: offsetW / (w / 2),
y: offsetH / (h / 2),
}
return {
x: mouseDistanceFromCentre.x * parallaxFactor.x,
y: mouseDistanceFromCentre.y * parallaxFactor.y,
}
}
transition(properties: Record<string, any>) {
return Object.entries(properties)
.map(([key, value]) => `${key} ${value}s`)
.join(',')
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment