From d188c25e6e87153873682cd07a52673092a67dd9 Mon Sep 17 00:00:00 2001 From: Chandler Swift Date: Wed, 11 Feb 2026 22:48:11 -0600 Subject: [PATCH] Slop: Add scrolling title Prompt: > Sometimes this album title is too long, and it overflows the box. I don't want it to do that. Instead, I want it to be cut off at max width, and then to have a marquee effect, translating left. For example: > `Monty Python and the` > becomes > `ty Python and the Hol` > becomes > `thon and the Holy Grail` > becomes > `the Holy Grail Monty Py` > (note a many-space gap between Grail and Monty as the title restarts) > > How can I create this effect? --- src/components/Album/Album.css | 56 +++++++++++++++++++++++++- src/components/Album/Album.tsx | 72 +++++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/components/Album/Album.css b/src/components/Album/Album.css index 9eeab20..c33c52f 100644 --- a/src/components/Album/Album.css +++ b/src/components/Album/Album.css @@ -21,13 +21,66 @@ font-weight: 600; letter-spacing: 0.3px; margin: 0; + flex: 1 1 auto; + min-width: 0; } .title-row { display: flex; align-items: center; gap: 12px; - flex-wrap: wrap; + flex-wrap: nowrap; + min-width: 0; + overflow: hidden; +} + +.title-viewport { + display: block; + overflow: hidden; + white-space: nowrap; + + /* Defaults; overridden inline when marquee is active */ + --marquee-gap: 96px; + --marquee-distance: 0px; + --marquee-duration: 0s; + --fade-edge: 18px; +} + +.title-viewport.is-marquee { + /* Subtle fade at the edges (marquee mode only) */ + mask-image: linear-gradient( + 90deg, + transparent, + #000 var(--fade-edge), + #000 calc(100% - var(--fade-edge)), + transparent + ); + mask-repeat: no-repeat; + mask-size: 100% 100%; +} + +.title-track { + display: inline-flex; + align-items: baseline; + gap: var(--marquee-gap); +} + +.title-text { + display: inline-block; +} + +.title-viewport.is-marquee .title-track { + will-change: transform; + animation: title-marquee var(--marquee-duration) linear infinite; +} + +@keyframes title-marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(calc(-1 * var(--marquee-distance))); + } } .subtitle { @@ -67,4 +120,5 @@ button.dismiss { padding: 6px 10px; box-shadow: 0 6px 18px rgba(255, 70, 70, 0.25); margin-left: auto; + flex: 0 0 auto; } diff --git a/src/components/Album/Album.tsx b/src/components/Album/Album.tsx index 2b84aed..f23be64 100644 --- a/src/components/Album/Album.tsx +++ b/src/components/Album/Album.tsx @@ -2,12 +2,82 @@ import Track from "./Track"; import "./Album.css"; import type { Album, Track as TrackType } from "../../album"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; + export default function Album({ album, dismissAlbum, play, nowPlaying }: { album: Album; dismissAlbum: () => void, play: (i: number) => void, nowPlaying: TrackType }) { + const titleViewportRef = useRef(null); + const titleTextRef = useRef(null); + + const [isMarquee, setIsMarquee] = useState(false); + const [marqueeDistancePx, setMarqueeDistancePx] = useState(0); + + // Tunables (constant px/sec + visible gap between repeats) + const marqueeGapPx = 96; + const marqueeSpeedPxPerSec = 60; + + useLayoutEffect(() => { + const viewportEl = titleViewportRef.current; + const textEl = titleTextRef.current; + if (!viewportEl || !textEl) return; + + let raf = 0; + const measure = () => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + const viewportWidth = viewportEl.clientWidth; + const textWidth = textEl.scrollWidth; + const shouldMarquee = textWidth > viewportWidth + 1; + + setIsMarquee(shouldMarquee); + setMarqueeDistancePx(shouldMarquee ? textWidth + marqueeGapPx : 0); + }); + }; + + measure(); + + const ro = new ResizeObserver(measure); + ro.observe(viewportEl); + + // Fonts can load after first paint and change measurements. + void (document as unknown as { fonts?: { ready: Promise } }).fonts?.ready.then(measure); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, [album.title]); + + const marqueeStyle = useMemo(() => { + if (!isMarquee) return undefined; + + const durationSec = marqueeSpeedPxPerSec > 0 ? marqueeDistancePx / marqueeSpeedPxPerSec : 0; + + return { + ["--marquee-gap" as never]: `${marqueeGapPx}px`, + ["--marquee-distance" as never]: `${marqueeDistancePx}px`, + ["--marquee-duration" as never]: `${durationSec}s`, + }; + }, [isMarquee, marqueeDistancePx]); + return (
-

{album.title}

+

+ + + {album.title} + {isMarquee ? ( + {album.title} + ) : null} + + +