@ar-dacity/ardacity-text-pressure
Version:
Dynamic text pressure effects for React applications
254 lines (213 loc) • 7.75 kB
JSX
"use client"
import { useEffect, useRef, useState } from "react"
const TextPressure = ({
text = "ardacity",
fontFamily = "Compressa VF",
// This font is just an example, you should not use it in commercial projects.
fontUrl = "https://res.cloudinary.com/dr6lvwubh/raw/upload/v1529908256/CompressaPRO-GX.woff2",
width = true,
weight = true,
italic = true,
alpha = false,
flex = true,
stroke = false,
scale = false,
textColor = "auto",
strokeColor = "#FF0000",
strokeWidth = 2,
className = "",
minFontSize = 24,
}) => {
const containerRef = useRef(null)
const titleRef = useRef(null)
const spansRef = useRef([])
const mouseRef = useRef({ x: 0, y: 0 })
const cursorRef = useRef({ x: 0, y: 0 })
const [fontSize, setFontSize] = useState(minFontSize)
const [scaleY, setScaleY] = useState(1)
const [lineHeight, setLineHeight] = useState(1)
const [detectedTextColor, setDetectedTextColor] = useState(textColor || "#000000")
const chars = text.split("")
const dist = (a, b) => {
const dx = b.x - a.x
const dy = b.y - a.y
return Math.sqrt(dx * dx + dy * dy)
}
// Detect theme and set text color accordingly
useEffect(() => {
if (textColor) {
setDetectedTextColor(textColor)
return
}
const detectTheme = () => {
// Check if prefers-color-scheme media query is supported
if (window.matchMedia) {
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches
setDetectedTextColor(isDarkMode ? "#FFFFFF" : "#000000")
} else {
// Fallback to checking background color brightness
if (containerRef.current) {
const parent = containerRef.current.parentElement
if (parent) {
const bgColor = window.getComputedStyle(parent).backgroundColor
const rgb = bgColor.match(/\d+/g)
if (rgb && rgb.length >= 3) {
// Calculate brightness using the formula: (0.299*R + 0.587*G + 0.114*B)
const brightness = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255
setDetectedTextColor(brightness > 0.5 ? "#000000" : "#FFFFFF")
} else {
setDetectedTextColor("#000000") // Default to black if can't detect
}
}
}
}
}
detectTheme()
// Listen for theme changes
const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const handleThemeChange = (e) => {
setDetectedTextColor(e.matches ? "#FFFFFF" : "#000000")
}
if (darkModeMediaQuery.addEventListener) {
darkModeMediaQuery.addEventListener("change", handleThemeChange)
} else if (darkModeMediaQuery.addListener) {
// For older browsers
darkModeMediaQuery.addListener(handleThemeChange)
}
return () => {
if (darkModeMediaQuery.removeEventListener) {
darkModeMediaQuery.removeEventListener("change", handleThemeChange)
} else if (darkModeMediaQuery.removeListener) {
darkModeMediaQuery.removeListener(handleThemeChange)
}
}
}, [textColor, containerRef.current])
useEffect(() => {
const handleMouseMove = (e) => {
cursorRef.current.x = e.clientX
cursorRef.current.y = e.clientY
}
const handleTouchMove = (e) => {
const t = e.touches[0]
cursorRef.current.x = t.clientX
cursorRef.current.y = t.clientY
}
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("touchmove", handleTouchMove, { passive: false })
if (containerRef.current) {
const { left, top, width, height } = containerRef.current.getBoundingClientRect()
mouseRef.current.x = left + width / 2
mouseRef.current.y = top + height / 2
cursorRef.current.x = mouseRef.current.x
cursorRef.current.y = mouseRef.current.y
}
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("touchmove", handleTouchMove)
}
}, [])
const setSize = () => {
if (!containerRef.current || !titleRef.current) return
const { width: containerW, height: containerH } = containerRef.current.getBoundingClientRect()
let newFontSize = containerW / (chars.length / 2)
newFontSize = Math.max(newFontSize, minFontSize)
setFontSize(newFontSize)
setScaleY(1)
setLineHeight(1)
requestAnimationFrame(() => {
if (!titleRef.current) return
const textRect = titleRef.current.getBoundingClientRect()
if (scale && textRect.height > 0) {
const yRatio = containerH / textRect.height
setScaleY(yRatio)
setLineHeight(yRatio)
}
})
}
useEffect(() => {
setSize()
window.addEventListener("resize", setSize)
return () => window.removeEventListener("resize", setSize)
}, [scale, text])
useEffect(() => {
let rafId
const animate = () => {
mouseRef.current.x += (cursorRef.current.x - mouseRef.current.x) / 15
mouseRef.current.y += (cursorRef.current.y - mouseRef.current.y) / 15
if (titleRef.current) {
const titleRect = titleRef.current.getBoundingClientRect()
const maxDist = titleRect.width / 2
spansRef.current.forEach((span) => {
if (!span) return
const rect = span.getBoundingClientRect()
const charCenter = {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
}
const d = dist(mouseRef.current, charCenter)
const getAttr = (distance, minVal, maxVal) => {
const val = maxVal - Math.abs((maxVal * distance) / maxDist)
return Math.max(minVal, val + minVal)
}
const wdth = width ? Math.floor(getAttr(d, 5, 200)) : 100
const wght = weight ? Math.floor(getAttr(d, 100, 900)) : 400
const italVal = italic ? getAttr(d, 0, 1).toFixed(2) : 0
const alphaVal = alpha ? getAttr(d, 0, 1).toFixed(2) : 1
span.style.opacity = alphaVal
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`
})
}
rafId = requestAnimationFrame(animate)
}
animate()
return () => cancelAnimationFrame(rafId)
}, [width, weight, italic, alpha, chars.length])
return (
<div ref={containerRef} className="relative w-full h-full overflow-hidden bg-transparent">
<style>{`
@font-face {
font-family: '${fontFamily}';
src: url('${fontUrl}');
font-style: normal;
}
.stroke span {
position: relative;
color: ${detectedTextColor};
}
.stroke span::after {
content: attr(data-char);
position: absolute;
left: 0;
top: 0;
color: transparent;
z-index: -1;
-webkit-text-stroke-width: ${strokeWidth}px;
-webkit-text-stroke-color: ${strokeColor};
}
`}</style>
<h1
ref={titleRef}
className={`text-pressure-title ${className} ${
flex ? "flex justify-between" : ""
} ${stroke ? "stroke" : ""} uppercase text-center`}
style={{
fontFamily,
fontSize: fontSize,
lineHeight,
transform: `scale(1, ${scaleY})`,
transformOrigin: "center top",
margin: 0,
fontWeight: 100,
color: stroke ? undefined : detectedTextColor,
}}
>
{chars.map((char, i) => (
<span key={i} ref={(el) => (spansRef.current[i] = el)} data-char={char} className="inline-block">
{char}
</span>
))}
</h1>
</div>
)
}
export default TextPressure