component
Bookshelf
Procedurally generated bookshelf with seeded books and one cuboid collider.
⌖ Explore the library in 3D → · View as Markdown
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 whatrunek 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