"use client";
import { motion, useReducedMotion } from "motion/react";
import {
useEffect,
useLayoutEffect,
useRef,
useCallback,
useState,
type CSSProperties,
} from "react";
import { cn } from "@/lib/utils";
import { reveal, revealDelay } from "@/lib/motion";
import styles from "./bookshelf.module.css";
const TRANSITION = { duration: 0.38, ease: [0.22, 1, 0.36, 1] as const };
const LIFT_TRANSITION = {
type: "spring",
stiffness: 340,
damping: 25,
mass: 0.7,
} as const;
export interface BookshelfBook {
id: string;
title: string;
author: string;
cover: { src: string; width: number; height: number; alt: string };
spine: { src: string; height: number; width: number };
}
export interface BookshelfProps {
/** Books render in the supplied order. IDs must be unique and stable. */
books: readonly BookshelfBook[];
/** Initially open book. Missing or unknown IDs start with every book closed. */
defaultBookId?: string | null;
label?: string;
emptyMessage?: string;
coverUnavailableMessage?: string;
/** Closed-book hover lift in pixels, clamped to 0 through 20. */
liftAmount?: number;
/** Optional once-only entrance for shelves below the fold. */
revealOnScroll?: boolean;
/**
* Shrink the books so the whole row, with any one book open, fits the
* available width. Without it a narrow screen scrolls the row sideways.
*/
fitToWidth?: boolean;
/**
* Called from the press itself whenever a book opens or closes, for
* feedback such as a sound. Browsers only allow sound that starts from a
* gesture, so this runs inside it.
*/
onToggle?: (open: boolean) => void;
className?: string;
}
/** Layout constants shared with bookshelf.module.css. */
const BOOK_EXTRA = 4;
const OPEN_EXTRA = 5;
const BOOK_GAP = 3;
const NARROW_BOOK_GAP = 2;
const SIDE_PADDING = 32;
const NARROW_SIDE_PADDING = 10;
const MIN_SCALE = 0.35;
/** The stylesheet tightens the row below this viewport width. */
const NARROW_QUERY = "(width < 480px)";
/**
* The largest scale at which every book fits `width` in one row, measured
* with the widest possible book open so opening one never overflows.
*/
function fitScale(
books: readonly BookshelfBook[],
width: number,
narrow: boolean,
) {
const padding = narrow ? NARROW_SIDE_PADDING : SIDE_PADDING;
const gap = narrow ? NARROW_BOOK_GAP : BOOK_GAP;
const spines = books.reduce((sum, book) => sum + book.spine.width, 0);
const widestOpening = Math.max(
0,
...books.map(
(book) =>
(book.spine.height * book.cover.width) / book.cover.height -
book.spine.width,
),
);
const fixed =
books.length * BOOK_EXTRA +
(OPEN_EXTRA - BOOK_EXTRA) +
Math.max(0, books.length - 1) * gap +
padding * 2;
const scale = (width - fixed) / (spines + widestOpening);
return { scale: Math.min(1, Math.max(MIN_SCALE, scale)), padding };
}
function BookCover({
book,
unavailableMessage,
load,
onSettled,
}: {
book: BookshelfBook;
unavailableMessage: string;
/** False until the shelf nears the viewport, so covers stay off the first page load. */
load: boolean;
/** Called once the cover has loaded or failed. */
onSettled?: () => void;
}) {
const [failed, setFailed] = useState(false);
return (
<span className={styles.cover}>
{failed ? (
<span className={styles.fallback}>
<span>{book.title}</span>
<span>{book.author}</span>
<span>{unavailableMessage}</span>
</span>
) : !load ? null : (
// Consumer artwork needs no Next.js loader or domain configuration.
// eslint-disable-next-line @next/next/no-img-element
<img
src={book.cover.src}
width={book.cover.width}
height={book.cover.height}
alt={book.cover.alt}
loading="eager"
decoding="async"
className={styles.coverImage}
onLoad={onSettled}
onError={() => {
setFailed(true);
onSettled?.();
}}
/>
)}
</span>
);
}
export function Bookshelf({
books,
defaultBookId = null,
label = "Bookshelf",
emptyMessage = "More books will find their way here.",
coverUnavailableMessage = "Cover unavailable",
liftAmount = 10,
revealOnScroll = false,
fitToWidth = false,
onToggle,
className,
}: BookshelfProps) {
const [selectedId, setSelectedId] = useState<string | null>(() =>
books.some((book) => book.id === defaultBookId) ? defaultBookId : null,
);
const selectedRef = useRef(selectedId);
const onToggleRef = useRef(onToggle);
useEffect(() => {
onToggleRef.current = onToggle;
});
/** Opens a book, or closes the open one, and reports it from the gesture. */
const choose = useCallback((next: string | null) => {
const previous = selectedRef.current;
if (previous === next) return;
selectedRef.current = next;
setSelectedId(next);
onToggleRef.current?.(next !== null);
}, []);
const activeId = books.some((book) => book.id === selectedId)
? selectedId
: null;
const [hoveredId, setHoveredId] = useState<string | null>(null);
// Layout animation is switched on the first time someone reaches for the
// shelf, before any click. While it is on, every render of the page makes
// Motion measure each book, which stalled page load for over a second.
const [layoutReady, setLayoutReady] = useState(false);
const readyLayout = () => {
if (!layoutReady) setLayoutReady(true);
};
const root = useRef<HTMLDivElement>(null);
// Covers are detailed artwork and nothing shows them until a book opens, so
// they wait until the shelf is a couple of screens away. They are then
// fetched together, well before anyone can reach a spine.
const [coversWanted, setCoversWanted] = useState(false);
// Covers are detailed vector artwork, and the browser parses each one on
// the main thread. Loading them all at once froze scrolling for seconds on
// phones, so they load one at a time, each in a quiet moment, never while
// the page is scrolling. A cover that is hovered or opened loads at once.
const [coverCount, setCoverCount] = useState(0);
const lastScroll = useRef(0);
useEffect(() => {
const onScroll = () => {
lastScroll.current = performance.now();
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const nextCover = useRef(0);
const loadNextCover = (index: number) => {
if (index !== nextCover.current) return;
nextCover.current = index + 1;
const attempt = () => {
if (performance.now() - lastScroll.current < 300) {
globalThis.setTimeout(attempt, 200);
return;
}
setCoverCount((count) => Math.max(count, index + 2));
};
if (typeof window.requestIdleCallback === "function") {
window.requestIdleCallback(attempt, { timeout: 1500 });
} else {
globalThis.setTimeout(attempt, 120);
}
};
// The shelf element only exists once there are books to show.
const hasBooks = books.length > 0;
useEffect(() => {
const element = root.current;
if (!element || typeof IntersectionObserver === "undefined") {
setCoversWanted(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setCoversWanted(true);
observer.disconnect();
}
},
{ rootMargin: "1600px 0px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [hasBooks]);
const rail = useRef<HTMLDivElement>(null);
const [fit, setFit] = useState<{ scale: number; padding: number } | null>(
null,
);
useLayoutEffect(() => {
const element = rail.current;
if (!fitToWidth || !element) return;
const measure = () => {
const next = fitScale(
books,
element.clientWidth,
window.matchMedia(NARROW_QUERY).matches,
);
setFit((current) =>
current &&
Math.abs(current.scale - next.scale) < 0.005 &&
current.padding === next.padding
? current
: next,
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [fitToWidth, books]);
const reduceMotion = useReducedMotion();
const instant = reduceMotion !== false;
const transition = instant ? { duration: 0 } : TRANSITION;
const lift = Number.isFinite(liftAmount)
? Math.min(20, Math.max(0, liftAmount))
: 10;
useEffect(() => {
function dismissOutside(event: PointerEvent) {
const target = event.target;
if (
target instanceof Element &&
root.current?.contains(target) &&
target.closest("[data-bookshelf-book]")
) {
return;
}
// The header floats over the page; pressing its controls (theme,
// search, GitHub, menu) must not close an open book.
if (target instanceof Element && target.closest("header")) {
return;
}
choose(null);
}
function dismissOnEscape(event: KeyboardEvent) {
if (event.key !== "Escape") return;
choose(null);
setHoveredId(null);
}
document.addEventListener("pointerdown", dismissOutside);
document.addEventListener("keydown", dismissOnEscape);
return () => {
document.removeEventListener("pointerdown", dismissOutside);
document.removeEventListener("keydown", dismissOnEscape);
};
}, [choose]);
useEffect(() => {
if (!activeId) return;
const book = root.current?.querySelector<HTMLElement>(
`[data-bookshelf-book="${CSS.escape(activeId)}"]`,
);
const position = book?.parentElement;
const railElement = rail.current;
if (!position || !railElement) return;
const max = railElement.scrollWidth - railElement.clientWidth;
const centered =
position.offsetLeft -
(railElement.clientWidth - position.offsetWidth) / 2;
const next = Math.max(0, Math.min(max, centered));
if (Math.abs(next - railElement.scrollLeft) > 1) {
railElement.scrollLeft = next;
}
}, [activeId, books]);
if (books.length === 0) {
return (
<div
role="group"
aria-label={label}
className={cn(styles.shelf, className)}
>
<p className="px-6 py-16 text-sm text-muted-foreground">
{emptyMessage}
</p>
</div>
);
}
return (
<div
ref={root}
role="group"
aria-label={label}
className={cn(styles.shelf, className)}
onPointerEnter={readyLayout}
onPointerDown={readyLayout}
onFocus={readyLayout}
style={
fitToWidth && fit
? ({
"--book-scale": fit.scale,
"--shelf-pad": `${fit.padding}px`,
} as CSSProperties)
: undefined
}
>
<noscript>
<style>{`.${styles.reveal} { opacity: 1 !important; transform: none !important; }`}</style>
</noscript>
<div ref={rail} className={styles.rail}>
<div className={styles.books}>
{books.map((book, index) => {
const active = activeId === book.id;
const bookStyle = {
"--book-height": `${book.spine.height}px`,
"--spine-width": `${book.spine.width}px`,
"--cover-ratio": book.cover.width / book.cover.height,
} as CSSProperties;
return (
<motion.div
key={book.id}
className={styles.reveal}
{...(revealOnScroll && !reduceMotion
? reveal({
y: 28,
delay: revealDelay(index, 0.06, 0.36),
margin: "0px 0px -120px 0px",
amount: 0.3,
})
: { initial: false })}
>
<motion.button
type="button"
layout={layoutReady ? "position" : false}
transition={transition}
data-bookshelf-book={book.id}
data-open={active}
className={styles.book}
style={bookStyle}
aria-label={`${book.title}, by ${book.author}`}
aria-pressed={active}
onPointerEnter={(event) => {
if (
event.pointerType === "mouse" &&
window.matchMedia("(hover: hover) and (pointer: fine)")
.matches
) {
setHoveredId(book.id);
}
}}
onPointerLeave={() => setHoveredId(null)}
onClick={() =>
choose(selectedRef.current === book.id ? null : book.id)
}
>
<motion.span
className={styles.artwork}
initial={false}
animate={{
y:
!instant && !active && hoveredId === book.id
? -lift
: 0,
}}
transition={
instant
? { duration: 0 }
: active
? TRANSITION
: LIFT_TRANSITION
}
>
<motion.span
className={styles.volume}
initial={false}
animate={{ opacity: active ? 1 : 0 }}
transition={{ duration: instant ? 0 : 0.18 }}
aria-hidden="true"
>
<BookCover
key={book.cover.src}
book={book}
unavailableMessage={coverUnavailableMessage}
// The book that starts open shows its cover at once.
load={
(coversWanted && index < Math.max(coverCount, 1)) ||
book.id === activeId ||
book.id === hoveredId
}
onSettled={() => loadNextCover(index)}
/>
</motion.span>
<motion.span
className={styles.spine}
initial={false}
animate={{ opacity: active ? 0 : 1 }}
transition={{ duration: instant ? 0 : 0.18 }}
aria-hidden="true"
>
{/* Artwork uses the supplied URL without a framework image loader. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={book.spine.src}
alt=""
width={book.spine.width}
height={book.spine.height}
loading="eager"
className={styles.spineImage}
/>
</motion.span>
</motion.span>
</motion.button>
</motion.div>
);
})}
</div>
</div>
<div className={styles.baseline} aria-hidden="true" />
</div>
);
}