component

Bookshelf

Procedurally generated bookshelf with seeded books and one cuboid collider.

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add bookshelf

Pulls sign, @react-three/drei@^10.7.7, @react-three/fiber@^9.6.1, @react-three/rapier@^2.2.0, @runek/core@^0.15.0, three@^0.184.0.

Use it

import { Bookshelf } from './runek/Bookshelf'

<Bookshelf position={[0, 0, 0]} seed={1} />

Or as a node in a world file (worlds as data):

{ "type": "Bookshelf", "props": { "position": [0, 0, 0], "seed": 1 } }

Props

Prop Type Default Description
position [x, y, z] [0, 0, 0] Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation [x, y, z] [0, 0, 0] Euler rotation [x, y, z] in radians.
width number 1.2 Outer dimensions in units.
height number 2 Height, in units.
depth number 0.3 Depth, in units.
shelves number 3 Number of rows. Defaults to 3; set 1 or 2 for a shorter case.
fill number 0 Fraction of shelf space filled with procedural decoration, 0–1. Defaults to 0 (an empty shelf); set it for a decorative, non-interactive shelf. Ignored when books is set.
color color palette wood Frame color. Defaults to the world palette’s wood slot.
backColor color palette woodDark Back-panel color. Defaults to the world palette’s woodDark slot.
seed number 1 Seed for the deterministic variation: same seed, same result.
books BookSpec[] Explicit, addressable books. When provided, these replace the procedural fill and are the only books rendered. Pass [] for an empty shelf and append to make books appear. A book is clickable when onBookSelect is set or it carries an href.
onBookSelect function (code only) Called with the clicked book. When omitted, a book’s href is navigated to.
reach number 3 How close the player’s avatar (its eyes) must be to a book to hover or click it, in units. Only applies while a Player is mounted; with a free camera every book is in reach.
label string Section label rendered above the shelf, e.g. "Guides". Drawn as a Sign in the world’s display face.
labelColor color palette accent Label color. Defaults to the world palette’s accent slot.
labelSize number 0.08 Label cap height in units.

BookSpec

Prop Type Default Description
id string required Stable identity, passed back to onBookSelect.
title string Shown as a hover label.
color color Spine color. Defaults to a seeded color.
href string Navigated to on click when no onBookSelect is given.
shelf number Row to place the book on, counted from the top (0 = top row). Clamped to the shelf’s row count. Books without a row auto-pack bottom-up around the placed ones.

Registry manifest

registry manifest /r/components/bookshelf.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add bookshelf fetches.

Browse the whole catalog in the gallery →.

Source (Bookshelf.tsx)
import { Html } from '@react-three/drei'
import { type ThreeEvent, useFrame } from '@react-three/fiber'
import { CuboidCollider, RigidBody } from '@react-three/rapier'
import { DEFAULT_REACH, rng, useLensPosition, useReach, useWorld, type Vec3 } from '@runek/core'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { Sign } from './Sign'

/**
 * An addressable book on the shelf. JSON-serializable so a whole shelf of
 * clickable books survives a worlds-as-data round-trip; behavior (the reader,
 * the overlay) stays in the host via `onBookSelect`.
 */
export interface BookSpec {
  /** Stable identity, passed back to `onBookSelect`. */
  id: string
  /** Shown as a hover label. */
  title?: string
  /** Spine color. Defaults to a seeded color. */
  color?: string
  /** Navigated to on click when no `onBookSelect` is given. */
  href?: string
  /**
   * Row to place the book on, counted from the top (0 = top row). Clamped to
   * the shelf's row count. Books without a row auto-pack bottom-up around the
   * placed ones.
   */
  shelf?: number
}

export interface BookshelfProps {
  position?: Vec3
  rotation?: Vec3
  /** Outer dimensions in units. */
  width?: number
  height?: number
  depth?: number
  /** Number of rows. Defaults to 3; set 1 or 2 for a shorter case. */
  shelves?: number
  /**
   * Fraction of shelf space filled with procedural decoration, 0–1. Defaults to
   * `0` (an empty shelf); set it for a decorative, non-interactive shelf. Ignored
   * when `books` is set.
   */
  fill?: number
  /** Frame color. Defaults to the world palette's `wood` slot. */
  color?: string
  /** Back-panel color. Defaults to the world palette's `woodDark` slot. */
  backColor?: string
  seed?: number
  /**
   * Explicit, addressable books. When provided, these replace the procedural
   * `fill` and are the only books rendered. Pass `[]` for an empty shelf and
   * append to make books appear. A book is clickable when `onBookSelect` is set
   * or it carries an `href`.
   */
  books?: BookSpec[]
  /** Called with the clicked book. When omitted, a book's `href` is navigated to. */
  onBookSelect?: (book: BookSpec) => void
  /** How close the player's avatar (its eyes) must be to a book to hover or click it, in units.
   *  Only applies while a `Player` is mounted; with a free camera every book is in reach. */
  reach?: number
  /**
   * Section label rendered above the shelf, e.g. `"Guides"`. Drawn as a `Sign`
   * in the world's `display` face.
   */
  label?: string
  /** Label color. Defaults to the world palette's `accent` slot. */
  labelColor?: string
  /** Label cap height in units. */
  labelSize?: number
}

interface Book {
  x: number
  y: number
  width: number
  height: number
  depth: number
  /** Spine tilt around z, radians — an occasional leaning book. */
  lean: number
  color: THREE.Color
  /** Set in configured mode: the book's identity and click behavior. */
  spec?: BookSpec
}

const HIGHLIGHT = new THREE.Color('#ffffff')

export function Bookshelf({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  width = 1.2,
  height = 2,
  depth = 0.3,
  shelves = 3,
  fill = 0,
  color,
  backColor,
  seed = 1,
  books: bookSpecs,
  onBookSelect,
  reach = DEFAULT_REACH,
  label,
  labelColor,
  labelSize = 0.08,
}: BookshelfProps) {
  const { unit, palette } = useWorld()
  const labelAt = useLensPosition()
  const frameColor = color ?? palette.wood
  const panelColor = backColor ?? palette.woodDark
  const w = width * unit
  const h = height * unit
  const d = depth * unit
  const plank = 0.03 * unit
  const inner = w - plank * 2
  const gap = (h - plank) / shelves
  const booksRef = useRef<THREE.InstancedMesh>(null)
  const [hovered, setHovered] = useState<number | null>(null)
  const inReach = useReach(reach)
  const lastHit = useRef<THREE.Vector3 | null>(null)

  const books = useMemo<Book[]>(() => {
    const next = rng(seed)
    const out: Book[] = []

    // Configured mode: render exactly the given books. Seeded geometry, but
    // identity/color come from the spec. A book with a `shelf` goes on that row
    // (0 = top); the rest pack into rows (bottom shelf first). Each row is
    // centered, so a partly-filled shelf still reads as intentional rather than
    // left-clustered.
    if (bookSpecs) {
      const sized = bookSpecs.map((spec) => {
        const bw = (0.075 + next() * 0.05) * unit
        const bh = gap * (0.6 + next() * 0.32)
        const hue = next() * 360
        const sat = 30 + next() * 25
        const light = 42 + next() * 20
        const bd = d * (0.55 + next() * 0.2)
        return {
          spec,
          bw,
          bh,
          bd,
          color: spec.color
            ? new THREE.Color(spec.color)
            : new THREE.Color(`hsl(${hue}, ${sat}%, ${light}%)`),
        }
      })

      const slack = 0.006 * unit
      // Row arrays are indexed bottom-up (row 0 renders lowest).
      const rows: (typeof sized)[] = Array.from({ length: shelves }, () => [])
      const used = new Array<number>(shelves).fill(0)
      const place = (row: number, book: (typeof sized)[number]) => {
        used[row] += (rows[row].length ? slack : 0) + book.bw
        rows[row].push(book)
      }

      for (const book of sized) {
        if (book.spec.shelf == null) continue
        const fromTop = Math.min(Math.max(book.spec.shelf, 0), shelves - 1)
        place(shelves - 1 - fromTop, book)
      }
      let cursor = 0
      for (const book of sized) {
        if (book.spec.shelf != null) continue
        while (cursor < shelves && rows[cursor].length && used[cursor] + slack + book.bw > inner) {
          cursor++
        }
        if (cursor >= shelves) break // out of shelf space
        place(cursor, book)
      }

      rows.forEach((row, shelf) => {
        const span = row.reduce((sum, b) => sum + b.bw, 0) + slack * Math.max(0, row.length - 1)
        let x = -span / 2
        for (const book of row) {
          out.push({
            x: x + book.bw / 2,
            y: -h / 2 + plank + gap * shelf + book.bh / 2,
            width: book.bw,
            height: book.bh,
            depth: book.bd,
            lean: 0, // upright: tidy, and a clean click target
            color: book.color,
            spec: book.spec,
          })
          x += book.bw + slack
        }
      })
      return out
    }

    // Procedural mode: seeded decorative fill (unchanged).
    for (let shelf = 0; shelf < shelves; shelf++) {
      let x = -inner / 2
      while (x < inner / 2) {
        const bw = (0.025 + next() * 0.03) * unit
        if (x + bw > inner / 2) break
        const bh = gap * (0.55 + next() * 0.35)
        const keep = next() < fill
        const lean = next() < 0.12 ? (next() - 0.5) * 0.22 : 0
        const hue = next() * 360
        const sat = 30 + next() * 25
        const light = 42 + next() * 20
        if (keep) {
          out.push({
            x: x + bw / 2,
            y: -h / 2 + plank + gap * shelf + bh / 2,
            width: bw,
            height: bh,
            depth: d * (0.5 + next() * 0.2),
            lean,
            color: new THREE.Color(`hsl(${hue}, ${sat}%, ${light}%)`),
          })
        }
        x += bw + 0.004 * unit
      }
    }
    return out
  }, [h, d, inner, gap, plank, shelves, fill, seed, unit, bookSpecs])

  useLayoutEffect(() => {
    const mesh = booksRef.current
    if (!mesh) return
    const dummy = new THREE.Object3D()
    const tint = new THREE.Color()
    books.forEach((book, i) => {
      const hot = i === hovered
      const grow = hot ? 1.12 : 1
      dummy.position.set(book.x, book.y, hot ? book.depth * 0.5 : 0)
      dummy.rotation.set(0, 0, book.lean)
      dummy.scale.set(book.width * grow, book.height * grow, book.depth * (hot ? 1.5 : 1))
      dummy.updateMatrix()
      mesh.setMatrixAt(i, dummy.matrix)
      mesh.setColorAt(i, hot ? tint.copy(book.color).lerp(HIGHLIGHT, 0.3) : book.color)
    })
    mesh.count = books.length
    mesh.instanceMatrix.needsUpdate = true
    if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true
  }, [books, hovered])

  const plankYs = Array.from({ length: shelves + 1 }, (_, i) => -h / 2 + plank / 2 + gap * i)

  // Interactive only when there's something a click can do. Decorative shelves
  // (no `books`) stay pure and event-free.
  const interactive = onBookSelect != null || books.some((b) => b.spec?.href != null)

  const handleClick = (e: ThreeEvent<MouseEvent>) => {
    const id = e.instanceId
    if (id == null || !inReach(e.point)) return
    const spec = books[id]?.spec
    if (!spec) return
    e.stopPropagation()
    if (onBookSelect) onBookSelect(spec)
    else if (spec.href) window.location.href = spec.href
  }

  const handleOut = () => {
    setHovered(null)
    document.body.style.cursor = 'auto'
  }

  const handleMove = (e: ThreeEvent<PointerEvent>) => {
    const spec = e.instanceId != null ? books[e.instanceId]?.spec : undefined
    lastHit.current = e.point.clone()
    if (!spec || !inReach(e.point)) {
      if (hovered != null) handleOut()
      return
    }
    e.stopPropagation()
    if (e.instanceId !== hovered) setHovered(e.instanceId ?? null)
    document.body.style.cursor = 'pointer'
  }

  // Walking away with the pointer still on a book leaves no pointer event to clear it.
  useFrame(() => {
    if (hovered != null && lastHit.current && !inReach(lastHit.current)) handleOut()
  })

  const hoveredBook = hovered != null ? books[hovered] : undefined

  return (
    <RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
      {/* one collider for the whole footprint — books stay visual-only */}
      <CuboidCollider args={[w / 2, h / 2, d / 2]} />

      <mesh castShadow receiveShadow position={[-w / 2 + plank / 2, 0, 0]}>
        <boxGeometry args={[plank, h, d]} />
        <meshStandardMaterial color={frameColor} />
      </mesh>
      <mesh castShadow receiveShadow position={[w / 2 - plank / 2, 0, 0]}>
        <boxGeometry args={[plank, h, d]} />
        <meshStandardMaterial color={frameColor} />
      </mesh>
      <mesh castShadow receiveShadow position={[0, 0, -d / 2 + plank / 2]}>
        <boxGeometry args={[w, h, plank]} />
        <meshStandardMaterial color={panelColor} />
      </mesh>

      {plankYs.map((y) => (
        <mesh key={`plank-${y.toFixed(4)}`} castShadow receiveShadow position={[0, y, 0]}>
          <boxGeometry args={[inner, plank, d]} />
          <meshStandardMaterial color={frameColor} />
        </mesh>
      ))}

      {/* Section label floating just above the shelf, on its front plane. */}
      {label && (
        <Sign
          position={[0, h / 2 + 0.05 * unit, d / 2]}
          size={labelSize}
          color={labelColor}
          anchorY="bottom"
        >
          {label}
        </Sign>
      )}

      {/* all books in one draw call */}
      {books.length > 0 && (
        <instancedMesh
          key={books.length}
          ref={booksRef}
          args={[undefined, undefined, books.length]}
          castShadow
          onClick={interactive ? handleClick : undefined}
          onPointerMove={interactive ? handleMove : undefined}
          onPointerOut={interactive ? handleOut : undefined}
        >
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial roughness={0.85} />
        </instancedMesh>
      )}

      {hoveredBook?.spec?.title && (
        <Html
          position={[
            hoveredBook.x,
            hoveredBook.y + hoveredBook.height / 2 + 0.12 * unit,
            hoveredBook.depth,
          ]}
          calculatePosition={labelAt}
          center
          distanceFactor={6}
          zIndexRange={[30, 0]}
        >
          <div
            style={{
              padding: '2px 8px',
              borderRadius: 6,
              background: 'rgba(10, 14, 20, 0.85)',
              color: '#f4efe6',
              font: '500 12px/1.4 system-ui, sans-serif',
              whiteSpace: 'nowrap',
              pointerEvents: 'none',
            }}
          >
            {hoveredBook.spec.title}
          </div>
        </Html>
      )}
    </RigidBody>
  )
}

Bookshelf.groundSitting = true