component

Staircase

Stepped staircase with per-step colliders: solid (a built-in stair on a floor) or floating (thin treads on sloped stringers, open underneath, to span a void).

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

Tune every prop in the workshop →

Add it

npx @runek/cli add staircase

Pulls @react-three/rapier@^2.2.0, @runek/core@^0.15.0.

Use it

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

<Staircase position={[0, 0, 0]} />

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

{ "type": "Staircase", "props": { "position": [0, 0, 0] } }

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.
seed number Accepted for the component contract; this component has no seeded variation yet.
steps number 6
totalHeight number 1.5 Total rise, in units. Ascends along +y and +z from the origin.
width number 1.2 Width, in units.
depth number 2.4 Total run (depth), in units.
form "solid" | "floating" "solid" solid fills each step down to the origin, a built-in stair that sits on a floor. floating lays thin treads on two sloped stringers, open underneath, for a flight that spans a void (an atrium, a loft, a mezzanine).
color color palette stone Defaults to the world palette’s stone slot.

Registry manifest

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

Browse the whole catalog in the gallery →.

Source (Staircase.tsx)
import { RigidBody } from '@react-three/rapier'
import { useWorld, type Vec3 } from '@runek/core'
import { useMemo } from 'react'

export type StaircaseForm = 'solid' | 'floating'

export interface StaircaseProps {
  position?: Vec3
  rotation?: Vec3
  /** Accepted for the component contract; this component has no seeded variation yet. */
  seed?: number
  steps?: number
  /** Total rise, in units. Ascends along +y and +z from the origin. */
  totalHeight?: number
  width?: number
  /** Total run (depth), in units. */
  depth?: number
  /**
   * `solid` fills each step down to the origin, a built-in stair that sits on a floor.
   * `floating` lays thin treads on two sloped stringers, open underneath, for a flight that
   * spans a void (an atrium, a loft, a mezzanine).
   */
  form?: StaircaseForm
  /** Defaults to the world palette's `stone` slot. */
  color?: string
}

interface Part {
  key: string
  position: Vec3
  rotation?: Vec3
  size: Vec3
}

/** Per-step boxes, each from the origin up to its tread. */
function solidParts(steps: number, w: number, rise: number, run: number): Part[] {
  return Array.from({ length: steps }, (_, i) => {
    const h = rise * (i + 1)
    return { key: `step-${i}`, position: [0, h / 2, run * (i + 0.5)], size: [w, h, run] }
  })
}

/** Thin treads resting on two stringers that follow the pitch line. */
function floatingParts(steps: number, w: number, rise: number, run: number, unit: number): Part[] {
  const tread = Math.min(0.07 * unit, rise * 0.6)
  const stringerW = 0.08 * unit
  const stringerD = 0.28 * unit
  const treads: Part[] = Array.from({ length: steps }, (_, i) => ({
    key: `tread-${i}`,
    position: [0, rise * (i + 1) - tread / 2, run * (i + 0.5)],
    // A hair of overlap so neighbouring treads read as one flight from below.
    size: [w, tread, run * 1.02],
  }))
  // The stringers' top edge runs under the treads' centers, from the first tread to the last,
  // so neither end pokes above a landing.
  const pitch = Math.atan2(rise, run)
  const length = Math.hypot(rise * (steps - 1), run * (steps - 1))
  const drop = stringerD / 2 / Math.cos(pitch)
  const midY = (rise * steps) / 2 + rise / 2 - tread - drop
  const midZ = (run * steps) / 2
  const stringers: Part[] = [-1, 1].map((side) => ({
    key: `stringer-${side}`,
    position: [side * (w / 2 - stringerW / 2), midY, midZ],
    rotation: [-pitch, 0, 0],
    size: [stringerW, stringerD, length],
  }))
  return [...treads, ...stringers]
}

export function Staircase({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  steps = 6,
  totalHeight = 1.5,
  width = 1.2,
  depth = 2.4,
  form = 'solid',
  color,
}: StaircaseProps) {
  const { unit, palette } = useWorld()
  const stoneColor = color ?? palette.stone

  const parts = useMemo(() => {
    const w = width * unit
    const rise = (totalHeight * unit) / steps
    const run = (depth * unit) / steps
    return form === 'floating'
      ? floatingParts(steps, w, rise, run, unit)
      : solidParts(steps, w, rise, run)
  }, [steps, totalHeight, width, depth, form, unit])

  return (
    <RigidBody type="fixed" colliders="cuboid" position={position} rotation={rotation}>
      {parts.map((part) => (
        <mesh
          key={part.key}
          castShadow
          receiveShadow
          position={part.position}
          rotation={part.rotation}
        >
          <boxGeometry args={part.size} />
          <meshStandardMaterial color={stoneColor} />
        </mesh>
      ))}
    </RigidBody>
  )
}