Feat/BOOK-424 campaign banner

Approved-by: Bianca Widstam
This commit is contained in:
Erik Tiekstra
2025-10-29 12:47:40 +00:00
parent 377c8886ad
commit 4c10989e8e
29 changed files with 1052 additions and 22 deletions

View File

@@ -0,0 +1,93 @@
"use client"
import { cx } from "class-variance-authority"
import { useEffect, useRef, useState } from "react"
import styles from "./marqueeText.module.css"
interface MarqueeTextProps
extends React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>> {
backgroundColor: string
textWrapperClassName?: string
}
export function MarqueeText({
backgroundColor,
children,
className,
textWrapperClassName,
...props
}: MarqueeTextProps) {
const textContainerRef = useRef<HTMLDivElement>(null)
const [dimensions, setDimensions] = useState({
containerWidth: 0,
contentWidth: 0,
isOverflowing: false,
})
useEffect(() => {
const element = textContainerRef.current
const parentElement = element?.parentElement
if (!parentElement) {
return
}
const resizeObserver = new ResizeObserver(() => {
const containerWidth = element.clientWidth
const contentWidth = element.scrollWidth
const isOverflowing = contentWidth > containerWidth
setDimensions({
containerWidth,
contentWidth,
isOverflowing,
})
if (isOverflowing && containerWidth > 0) {
const scrollDistance = contentWidth - containerWidth
parentElement.style.setProperty(
"--scroll-distance",
`${scrollDistance}px`
)
// Calculate dynamic animation duration based on scroll distance
// This is done to avoid long scrolling durations for small distances and vice versa
// Base formula: minimum 2s, add 50ms per pixel of scroll distance
const baseDuration = 2
const durationPerPixel = 0.05
const calculatedDuration = Math.max(
baseDuration,
baseDuration + scrollDistance * durationPerPixel
)
parentElement.style.setProperty(
"--animation-duration",
`${calculatedDuration}s`
)
}
})
resizeObserver.observe(element)
return () => resizeObserver.disconnect()
}, [])
return (
<div
className={cx(styles.marqueeText, className)}
style={
{ "--marquee-background-color": backgroundColor } as React.CSSProperties
}
{...props}
>
<div
ref={textContainerRef}
className={cx(styles.textWrapper, textWrapperClassName, {
[styles.overflowing]: dimensions.isOverflowing,
})}
>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
.marqueeText {
background-color: var(--marquee-background-color);
position: relative;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
&:has(.overflowing) {
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 12px;
background: linear-gradient(
to right,
var(--marquee-background-color) 0%,
transparent 100%
);
z-index: 2;
pointer-events: none;
opacity: 0;
animation: leftShadow var(--animation-duration, 8s) linear infinite;
}
&::after {
content: "";
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 12px;
background: linear-gradient(
to left,
var(--marquee-background-color) 0%,
transparent 100%
);
z-index: 2;
pointer-events: none;
opacity: 1;
animation: rightShadow var(--animation-duration, 8s) linear infinite;
}
&:has(.overflowing:hover)::before,
&:has(.overflowing:hover)::after {
animation-play-state: paused;
}
}
}
.textWrapper {
display: flex;
scrollbar-width: none;
align-items: center;
scroll-behavior: smooth;
* {
flex-shrink: 0;
white-space: nowrap;
}
&.overflowing {
animation: autoScrollText var(--animation-duration, 8s) ease-in-out infinite;
&:hover {
animation-play-state: paused;
}
}
}
@keyframes autoScrollText {
0%,
15% {
transform: translateX(0);
}
80%,
100% {
transform: translateX(calc(-1 * var(--scroll-distance, 50px)));
}
}
@keyframes leftShadow {
0%,
16% {
opacity: 0;
}
17%,
100% {
opacity: 1;
}
}
@keyframes rightShadow {
0%,
79% {
opacity: 1;
}
80%,
100% {
opacity: 0;
}
}