Skip to content

Instantly share code, notes, and snippets.

@jonastreub
Created May 11, 2019 16:49
Show Gist options
  • Save jonastreub/805b3f9f8a980c701d94bbf7c2aa39a9 to your computer and use it in GitHub Desktop.
Save jonastreub/805b3f9f8a980c701d94bbf7c2aa39a9 to your computer and use it in GitHub Desktop.
Allow div elements to transition relative position changes.
import { useRef, useLayoutEffect, RefObject } from "react"
interface PositionTransitionOptions extends Partial<TransitionOptions> {
/** A list of {@link Dependencies} for the position of the element. This could for example be the index in a list, the window size or a combination of those. When the dependencies change a transition can happen. */
dependencies: Dependencies
/** The element ref can be passed in if you don't want to use the ref returned by the hook. */
ref?: RefObject<HTMLDivElement>
}
interface PositionInfo {
lastPosition: Point
originalTransition: string
}
/**
* The usePositionTransition hook allows div elements to transition changes in position.
* @param options - An {@link PositionTransitionOptions} object exposing all position transition features. A list of dependencies for the size of the element is required.
* @returns A ref for a div element.
*/
export function usePositionTransition(options: PositionTransitionOptions): RefObject<HTMLDivElement> {
const ref = useRef<HTMLDivElement>(null)
const activeRef = options.ref ? options.ref : ref
const pointInfo = useRef<PositionInfo | null>(null)
useLayoutEffect(() => {
const element = activeRef.current
if (!element) return
const positionInfo = pointInfo.current
if (!positionInfo) {
pointInfo.current = {
lastPosition: elementOffset(element),
originalTransition: element.style.transition,
}
return
}
resetOffset(element)
const newPoint = elementOffset(element)
const { lastPosition } = positionInfo
const xDidChange = newPoint.x !== lastPosition.x
const yDidChange = newPoint.y !== lastPosition.y
const willAnimate = xDidChange || yDidChange
if (!willAnimate) return
positionInfo.lastPosition = newPoint
const d = Point.delta(newPoint, lastPosition)
element.style.transition = ""
element.style.transform = `translateX(${d.x}px) translateY(${d.y}px)`
// force styles to be applied
element.getBoundingClientRect()
element.style.transition = transitionValue("transform", options)
element.style.transform = ""
function resetOffsetHandler() {
if (element && positionInfo) {
resetOffset(element, positionInfo.originalTransition)
}
}
const duration = transitionDuration(options)
const resetDelay = transitionDurationToMilliseconds(duration) + 50
const handle = setTimeout(resetOffsetHandler, resetDelay)
return () => {
clearTimeout(handle)
resetOffsetHandler()
}
}, toDependencyArray(options.dependencies))
return activeRef
}
function elementOffset(element: HTMLDivElement): Point {
return {
x: element.offsetLeft,
y: element.offsetTop,
}
}
function resetOffset(element: HTMLDivElement, originalTransition?: string) {
element.style.transition = originalTransition || ""
element.style.transform = ""
}
interface TransitionOptions {
/** The duration of the transition in CSS format. The value can be in seconds or millisecond. Defaults to 0.2s. */
duration: CSSProperties["transitionDuration"]
/** The transition timing function in CSS format. Defaults to cubic-bezier(0.2, 0, 0, 1). */
timingFunction: CSSProperties["transitionTimingFunction"]
}
export function transitionDuration(options: Partial<TransitionOptions>): string {
return options.duration || "0.2s"
}
export function transitionTimingFunction(options: Partial<TransitionOptions>): string {
return options.timingFunction || "cubic-bezier(0.2, 0, 0, 1)"
}
/** Generate a CSS transition value from CSS properties and {@link TransitionOptions}. */
function transitionValue(
properties: keyof CSSProperties | (keyof CSSProperties)[],
options: Partial<TransitionOptions>
): string {
const duration = transitionDuration(options)
const timingFunction = transitionTimingFunction(options)
const transitionProperties = Array.isArray(properties) ? properties : [properties]
return transitionProperties.map(prop => `${prop} ${duration} ${timingFunction}`).join(", ")
}
/** Convert duration from CSS format to milliseconds. */
function transitionDurationToMilliseconds(duration: CSSProperties["transitionDuration"]): number {
if (!duration) return 0
const trimmedValue = duration.trim()
const multiplier = trimmedValue.endsWith("ms") ? 1 : 1000
const value = parseFloat(duration)
if (isNaN(value)) return 0
return value * multiplier
}
type DependencyValue = number | string | boolean
/** An list of number, string, or boolean values that a hook depends on. */
type Dependencies = DependencyValue | DependencyValue[]
/** Generate a unique key from {@link Dependencies}. */
function keyFromDependencies(dependencies: Dependencies): string {
if (Array.isArray(dependencies)) {
return dependencies.map(toString).join("-")
} else {
return toString(dependencies)
}
}
function toDependencyArray(dependencies: Dependencies): Readonly<DependencyValue[]> {
if (Array.isArray(dependencies)) {
return dependencies
} else {
return [dependencies]
}
}
function toString(value: number | string | boolean): string {
if (typeof value === "string") return value
if (typeof value === "number") return value.toString()
if (value === true || value === false) return `${value}`
return "unknown"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment