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?
This commit is contained in:
parent
0a5afe7210
commit
d188c25e6e
2 changed files with 126 additions and 2 deletions
|
|
@ -21,13 +21,66 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-row {
|
.title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
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 {
|
.subtitle {
|
||||||
|
|
@ -67,4 +120,5 @@ button.dismiss {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
box-shadow: 0 6px 18px rgba(255, 70, 70, 0.25);
|
box-shadow: 0 6px 18px rgba(255, 70, 70, 0.25);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,82 @@ import Track from "./Track";
|
||||||
import "./Album.css";
|
import "./Album.css";
|
||||||
import type { Album, Track as TrackType } from "../../album";
|
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 }) {
|
export default function Album({ album, dismissAlbum, play, nowPlaying }: { album: Album; dismissAlbum: () => void, play: (i: number) => void, nowPlaying: TrackType }) {
|
||||||
|
const titleViewportRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const titleTextRef = useRef<HTMLSpanElement | null>(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<void> } }).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 (
|
return (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
<div className="title-row">
|
<div className="title-row">
|
||||||
<h1 className="title" id="release-title">{album.title}</h1>
|
<h1 className="title" id="release-title">
|
||||||
|
<span
|
||||||
|
ref={titleViewportRef}
|
||||||
|
className={`title-viewport${isMarquee ? " is-marquee" : ""}`}
|
||||||
|
style={marqueeStyle}
|
||||||
|
title={album.title}
|
||||||
|
>
|
||||||
|
<span className="title-track">
|
||||||
|
<span ref={titleTextRef} className="title-text">{album.title}</span>
|
||||||
|
{isMarquee ? (
|
||||||
|
<span className="title-text title-text--clone">{album.title}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
<button type="button" className="dismiss" onClick={dismissAlbum}>✕</button>
|
<button type="button" className="dismiss" onClick={dismissAlbum}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="subtitle" id="release-artist">
|
<p className="subtitle" id="release-artist">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue