Bookshelf

A single-row bookshelf with spring hover lift, flat cover reveals, keyboard selection, and horizontal scrolling on narrow screens.

Props

Hover lift10px

Installation

Bookshelf is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @rayyan/bookshelf

The @rayyan namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the Components page.

Usage

import { Bookshelf, type BookshelfBook } from "@/components/ui/bookshelf";
 
const books: BookshelfBook[] = [
  {
    id: "design-notes",
    title: "Design notes",
    author: "Maya Chen",
    cover: {
      src: "/books/design-notes.svg",
      width: 600,
      height: 900,
      alt: "Design notes by Maya Chen, blue cover",
    },
    spine: { src: "/books/design-notes-spine.svg", height: 260, width: 36 },
  },
];
 
export function ReadingList() {
  return <Bookshelf books={books} defaultBookId="design-notes" />;
}

Supply your own cover and spine images at the paths in your data. The example paths are placeholders; the portfolio's book artwork is not bundled with the component. Images use standard browser loading, so no Next.js image loader, remote domain configuration, font, or provider is required. SVG, PNG, WebP, and JPEG artwork work.

The component ships with its CSS module and the shared motion helper. Keep those files when copying the source manually. The registry installs them together.

Props

The component exports BookshelfProps and BookshelfBook for typed wrappers and collections.

PropTypeDefaultNotes
books Requiredreadonly BookshelfBook[]-Books render in the supplied order. Filter and sort your collection before passing it.
defaultBookIdstring | nullnullInitially open book. Unknown IDs start with all books closed. This is an initial value, not a controlled selection.
labelstring"Bookshelf"Accessible label for the shelf.
emptyMessagestring"More books will find their way here."Text rendered for an empty collection.
coverUnavailableMessagestring"Cover unavailable"Fallback text shown with title and author if a cover fails to load.
liftAmountnumber10Closed-book hover lift in pixels, clamped to 0 through 20. Nonfinite values use 10.
revealOnScrollbooleanfalseOnce-only 28px entrance with a 60ms stagger, capped at 360ms. Intended for shelves below the fold.
classNamestring-Extra classes for the root. The shelf has no section border or heading.

Book fields

FieldTypeDefaultNotes
id Requiredstring-Unique, stable identifier for selection and React keys.
title Requiredstring-Book title, included in the button's accessible label.
author Requiredstring-Author name, included in the button's accessible label.
cover Required{ src: string; width: number; height: number; alt: string }-Cover artwork and positive intrinsic dimensions. Their ratio determines the open width.
spine Required{ src: string; height: number; width: number }-Spine artwork and positive display dimensions in pixels. Height sets the book's height; width sets the closed spine width.

Use trusted image URLs and positive, finite dimensions. Array changes update the rendered books; a selected ID absent from the current array is treated as closed. A changed cover URL retries loading after an earlier failure.

Interaction and accessibility

Click, tap, Enter, or Space opens a book. Activate it again to close it. Escape or a pointer press outside the books closes the selection. Header controls leave the selection in place. Focusing a book alone does not open it. Buttons expose their title and author, aria-pressed state, and a visible focus outline.

Fine-pointer hover gently lifts a closed book. An open cover stays on the shelf and crossfades in flat, without perspective rotation or drop shadows. Set liftAmount={0} to disable hover lift. Reduced motion and touch disable lift automatically.

Every width keeps one continuous row. Narrow containers scroll horizontally, and selecting a book brings it into view without moving the page. Covers stay mounted and eagerly loaded, clipped within each book so hidden artwork does not widen the rail.

The optional scroll entrance uses the shared reveal helper with a 120px bottom viewport inset. Reduced motion and no JavaScript keep the books visible. Without JavaScript, the initial cover and spines render as a static shelf.

Styling

The CSS module uses semantic theme variables with fallback values. The ledge is one continuous surface without extra frame borders. Below 480px, books scale to 78% and the ledge spans the available width.

The showcase uses the homepage's book collection and initial selection. Its Props panel adjusts hover lift from 0 to 20px. Reset restores the original 10px lift.

Manual installation

Rather not use the CLI? Everything Bookshelf needs is below. Install its dependencies, then copy all 3 files into the matching path in your project.

Dependencies

$npm install motion

Source files

components/ui/bookshelf.tsx282 lines
"use client";

import { motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, 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;
  className?: string;
}

function BookCover({
  book,
  unavailableMessage,
}: {
  book: BookshelfBook;
  unavailableMessage: string;
}) {
  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>
      ) : (
        // 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="sync"
          className={styles.coverImage}
          onError={() => setFailed(true)}
        />
      )}
    </span>
  );
}

export function Bookshelf({
  books,
  defaultBookId = null,
  label = "Bookshelf",
  emptyMessage = "More books will find their way here.",
  coverUnavailableMessage = "Cover unavailable",
  liftAmount = 10,
  revealOnScroll = false,
  className,
}: BookshelfProps) {
  const [selectedId, setSelectedId] = useState<string | null>(() =>
    books.some((book) => book.id === defaultBookId) ? defaultBookId : null,
  );
  const activeId = books.some((book) => book.id === selectedId)
    ? selectedId
    : null;
  const [hoveredId, setHoveredId] = useState<string | null>(null);
  const root = useRef<HTMLDivElement>(null);
  const rail = useRef<HTMLDivElement>(null);
  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;
      }
      setSelectedId(null);
    }

    function dismissOnEscape(event: KeyboardEvent) {
      if (event.key !== "Escape") return;
      setSelectedId(null);
      setHoveredId(null);
    }

    document.addEventListener("pointerdown", dismissOutside);
    document.addEventListener("keydown", dismissOnEscape);
    return () => {
      document.removeEventListener("pointerdown", dismissOutside);
      document.removeEventListener("keydown", dismissOnEscape);
    };
  }, []);

  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)}
    >
      <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="position"
                  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={() =>
                    setSelectedId((current) =>
                      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}
                      />
                    </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>
  );
}