component
Book
A single procedural book — standing, lying, or open — with a seeded cover and optional click interaction; the standalone sibling of Bookshelf's instanced spines.
⌖ Explore the library in 3D → · View as Markdown
Add it
npx @runek/cli add book
Pulls @react-three/drei@^10.7.7, @react-three/fiber@^9.6.1, @runek/core@^0.15.0, three@^0.184.0.
Use it
import { Book } from './runek/Book'
<Book position={[0, 0, 0]} seed={1} />
Or as a node in a world file (worlds as data):
{ "type": "Book", "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 |
0.22 |
Cover width (spine to fore-edge), in units. |
height |
number |
0.3 |
Spine length, in units. |
thickness |
number |
0.05 |
Closed page-block thickness, in units. |
pose |
"standing" | "lying" | "open" |
"lying" |
lying rests on its back cover (spine on the -x edge); standing stands upright on its bottom edge; open lies opened flat at the spine. |
color |
color |
Cover color. Defaults to a seeded cloth-bound color. | |
pageColor |
color |
"#efe7d2" |
|
title |
string |
Shown as a hover label when the book is interactive. | |
href |
string |
Navigated to on click when no onSelect is given. |
|
onSelect |
function (code only) |
Called on click. Optional, so the book still renders from data. | |
reach |
number |
3 |
How close the player’s avatar (its eyes) must be to hover or click the book, in units. Only applies while a Player is mounted; with a free camera it’s always in reach. |
seed |
number |
1 |
Seed for the deterministic variation: same seed, same result. |
Registry manifest
registry manifest /r/components/book.json Self-contained JSON: inlined source plus resolved dependencies, exactly whatrunek add book fetches.
Browse the whole catalog in the gallery →.
Source (Book.tsx)
import { Html } from '@react-three/drei'
import { type ThreeEvent, useFrame } from '@react-three/fiber'
import { DEFAULT_REACH, rng, useLensPosition, useReach, useWorld, type Vec3 } from '@runek/core'
import { useMemo, useRef, useState } from 'react'
import type { Vector3 } from 'three'
export type BookPose = 'standing' | 'lying' | 'open'
export interface BookProps {
position?: Vec3
rotation?: Vec3
/** Cover width (spine to fore-edge), in units. */
width?: number
/** Spine length, in units. */
height?: number
/** Closed page-block thickness, in units. */
thickness?: number
/**
* `lying` rests on its back cover (spine on the -x edge); `standing` stands
* upright on its bottom edge; `open` lies opened flat at the spine.
*/
pose?: BookPose
/** Cover color. Defaults to a seeded cloth-bound color. */
color?: string
pageColor?: string
/** Shown as a hover label when the book is interactive. */
title?: string
/** Navigated to on click when no `onSelect` is given. */
href?: string
/** Called on click. Optional, so the book still renders from data. */
onSelect?: () => void
/** How close the player's avatar (its eyes) must be to hover or click the book, in units.
* Only applies while a `Player` is mounted; with a free camera it's always in reach. */
reach?: number
seed?: number
}
/**
* A single procedural book — the standalone, placeable sibling of `Bookshelf`'s
* instanced spines. Put it on a table, a pedestal, or a reading stand; give it
* an `href` or `onSelect` and it becomes a clickable artifact. Decorative: no
* collider.
*/
export function Book({
position = [0, 0, 0],
rotation = [0, 0, 0],
width = 0.22,
height = 0.3,
thickness = 0.05,
pose = 'lying',
color,
pageColor = '#efe7d2',
title,
href,
onSelect,
reach = DEFAULT_REACH,
seed = 1,
}: BookProps) {
const { unit } = useWorld()
const labelAt = useLensPosition()
const [hovered, setHovered] = useState(false)
const inReach = useReach(reach)
const lastHit = useRef<Vector3 | null>(null)
const hover = (on: boolean) => {
if (on !== hovered) setHovered(on)
document.body.style.cursor = on ? 'pointer' : 'auto'
}
// Walking away with the pointer still on the book leaves no pointer event to clear it.
useFrame(() => {
if (hovered && lastHit.current && !inReach(lastHit.current)) hover(false)
})
const w = width * unit
const h = height * unit
const t = thickness * unit
const board = Math.max(t * 0.16, 0.004 * unit)
const coverColor = useMemo(() => {
if (color) return color
const next = rng(seed)
return `hsl(${Math.round(next() * 360)}, ${Math.round(35 + next() * 20)}%, ${Math.round(
35 + next() * 15,
)}%)`
}, [color, seed])
const interactive = onSelect != null || href != null
const handlers = interactive
? {
onClick: (e: ThreeEvent<MouseEvent>) => {
if (!inReach(e.point)) return
e.stopPropagation()
if (onSelect) onSelect()
else if (href) window.location.href = href
},
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
lastHit.current = e.point.clone()
const near = inReach(e.point)
if (near) e.stopPropagation()
hover(near)
},
onPointerOut: () => hover(false),
}
: {}
// The closed book in its lying frame: x = cover width, y = thickness,
// z = spine length; resting on y = 0 with the spine along the -x edge.
const closed = (
<group>
<mesh castShadow position={[0.01 * unit, t / 2, 0]}>
<boxGeometry args={[w * 0.94, Math.max(t - board * 2, t * 0.5), h * 0.95]} />
<meshStandardMaterial color={pageColor} roughness={0.9} />
</mesh>
<mesh castShadow position={[0, board / 2, 0]}>
<boxGeometry args={[w, board, h]} />
<meshStandardMaterial color={coverColor} roughness={0.75} />
</mesh>
<mesh castShadow position={[0, t - board / 2, 0]}>
<boxGeometry args={[w, board, h]} />
<meshStandardMaterial color={coverColor} roughness={0.75} />
</mesh>
<mesh castShadow position={[-w / 2 + board / 2, t / 2, 0]}>
<boxGeometry args={[board, t, h]} />
<meshStandardMaterial color={coverColor} roughness={0.75} />
</mesh>
</group>
)
// One opened half: hinged at x = 0, extending toward +x; mirror with scaleX.
const openTilt = 0.14
const halfPages = Math.max((t - board * 2) / 2, t * 0.25)
const openHalf = (side: 1 | -1) => (
<group key={side} scale={[side, 1, 1]} rotation={[0, 0, -openTilt]}>
<mesh castShadow position={[w / 2, board / 2, 0]}>
<boxGeometry args={[w, board, h]} />
<meshStandardMaterial color={coverColor} roughness={0.75} />
</mesh>
<mesh castShadow position={[(w / 2) * 0.98, board + halfPages / 2, 0]}>
<boxGeometry args={[w * 0.92, halfPages, h * 0.94]} />
<meshStandardMaterial color={pageColor} roughness={0.9} />
</mesh>
</group>
)
const body =
pose === 'open' ? (
<group>{[1 as const, -1 as const].map(openHalf)}</group>
) : pose === 'standing' ? (
// Lift by half the spine length, then tip the lying frame upright.
<group position={[0, h / 2, 0]} rotation={[-Math.PI / 2, 0, 0]}>
{closed}
</group>
) : (
closed
)
const labelY =
pose === 'standing'
? h + 0.1 * unit
: pose === 'open'
? board + halfPages + 0.1 * unit
: t + 0.1 * unit
return (
<group position={position} rotation={rotation}>
<group scale={hovered ? 1.08 : 1} {...handlers}>
{body}
</group>
{hovered && title && (
<Html
position={[0, labelY, 0]}
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',
}}
>
{title}
</div>
</Html>
)}
</group>
)
}