Interactive 3D Object
Turns any mesh/group in an R3F scene into a premium interactive object:
hover → soft ring + glow + cursor; click → ripple + burst + breathing select
ring + floating label; live status tints everything. Three layers, each a
single source of truth:
tokens.js the FEEL — colors, timings, easings; retune the whole system here
status.js the MEANING — pure fn (kind, id, snapshot) → { status, label }
ObjectFX the RENDER — geometry-agnostic overlay, all FX in ONE useFrame
Built for React Three Fiber 8 + drei 9 + three 0.169. Copy-paste code in
REFERENCE.md.
Sibling skills this composes with:
r3f-hero-camera — flies the camera to the object this skill makes selectable.
guided-placement — drag choreography on top of the same hover/select layer.
The FX pieces
All mounted by one <ObjectFX> child at the object's footprint center; every
piece is driven by envelope tweens in refs — zero per-frame React state.
| Piece |
Behaviour |
Reads |
| Hover ring |
thin cool ring, 120ms fade, hidden once selected |
hovered |
| Select ring |
expands 0.4→1 with easeOutBack overshoot (250ms), breathes, spins while busy, tints to status |
selected, status |
| Click ripple |
one-shot expanding ring, re-armed on select rising edge |
edge of selected |
| Glow halo |
additive billboard sprite (composer-free bloom stand-in): brightens on hover, blooms on select, breathes while busy |
hovered, selected, status |
| Particle burst |
16 points fly out + fall + fade (600ms) on select |
edge of selected |
| Floating label |
drei Html, constant screen size, fades/slides in on single-select, pointerEvents: none |
selected, label |
Single vs multi select get different accents (brand color vs blue) so "one" vs
"many" read at a glance.
Workflow (checklist)
- Copy the three layers —
tokens.js, status.js, ObjectFX.jsx
(REFERENCE §1–3). Map your domain's states into STATUS_COLORS keys
(RUNNING / WARNING / ALARM / OFFLINE / NEUTRAL) in status.js.
- Wire each object (REFERENCE §4): pointer handlers on its group
(
stopPropagation, cursor swap, hovered {kind, id} into shared state),
<ObjectFX> as a child sized from the object's real footprint.
- Mount ONE
<HoverTooltip> at scene level (REFERENCE §5) — intent delay,
anchor glued to the object every frame, content resolved per kind.
- Action previews (optional, REFERENCE §6): HUD buttons spread
useActionPreview(intent); in-scene consumers telegraph the effect on hover.
- Tune in tokens only. If a change needs edits outside
tokens.js, the
knob is missing from the token file — add it there.
Core principles
- Discrete in state, continuous in refs. React state changes only on
hover/select/status edges; every per-frame value (envelopes, scales,
opacities) lives in refs inside one
useFrame. Dragging a slider through
your scene should show zero re-renders from FX.
- Envelopes, not booleans. Each signal drives a 0→1 envelope eased with
frame-rate-independent damping (
k = 1 − e^(−rate·dt), rate derived from the
token duration). Visuals multiply envelopes — hover ring × (1 − sel) hands
over to the select ring with no pop.
- One-shots re-arm on rising edges. Ripple and burst fire when
selected
flips false→true (tracked in a ref), never on re-render.
- Status is a pure function of a state snapshot — no subscriptions, safe in
useFrame, and every visual (ring, glow, tooltip, label dot) reads the same
answer. Unknown object → OFFLINE, so a stale id never renders as "running".
- Geometry-agnostic overlay. ObjectFX takes
radius / labelY props from
the object's real footprint — the same component serves a robot, a crate, a
station. Never bake one object's dimensions in.
- Tooltip waits for intent. 200ms delay, re-armed on every hover change —
flicking the pointer across a crowd shows nothing.
- Subscribe narrowly. Each object selects only its own hover flag and
status from the store (
s.hovered?.id === myId), not the whole hover object
— or every pointer move re-renders the entire fleet.
- Previews telegraph, clicks commit. Hovering a verb shows its effect
(route, target, ghost); leaving clears it; clicking clears it too — the
action commits, so a lingering preview is stale UI.
Pitfalls
- Emissive/FX colors wash out under tone mapping —
toneMapped={false} on
every ring/glow/burst material. And saturated ≠ glowing: real halo = additive
blending on a radial-gradient sprite (or selective bloom), not a brighter hex.
- Ground rings z-fight the floor — lift slightly (+0.01–0.03),
depthWrite={false}, set renderOrder if stacked.
- Html overlays swallow clicks — every display-only
Html
(label, tooltip) needs pointerEvents: none or it eats the object's next
pointerdown (often only after first selection — the "second click dies" bug).
- Clamp dt (
Math.min(dt, 0.05)) in the FX useFrame — a tab-switch frame
otherwise teleports every tween to its end.
stopPropagation on hover/click or objects behind the pointer also fire;
restore document.body.style.cursor on pointer-out and unmount.
- Share one glow texture (module-level CanvasTexture) — one per object leaks
GPU memory across a fleet.
- Stable typed arrays for particles — allocate
Float32Array once
(useMemo), mutate + needsUpdate; recreating the attribute every burst
reallocates GPU buffers.
- X-ray/ghosting must clone materials. GLTF materials are shared across
clones — mutate one and every instance ghosts. Clone per mesh, stash the
original in
userData, restore AND dispose() the clone after
(REFERENCE §7).
- Cap live
Html instances. Labels on select-only (not hover-all) and one
scene-level tooltip keep the DOM overlay count ~O(selection), not O(objects).
- Hover state must survive fast object swaps — key the hover as
{kind, id} in one shared slot; per-object local useState strands a stuck
ring when the pointer jumps between adjacent objects in one frame.
1---2name: interactive-3d-object3description: AAA hover/select feedback for interactive objects in a React Three Fiber scene — a design-token file so the whole feel is coherent and tunable from one place, a status→color language, a geometry-agnostic ObjectFX overlay (soft hover ring, breathing status-tinted select ring, click ripple, additive glow halo, select particle burst, floating label), a delayed hover tooltip that stays glued to moving objects, and action-preview telegraphing for HUD buttons. Use when making 3D objects hoverable/clickable, adding selection rings, glow or highlight effects, status color coding, object labels or tooltips, or "show what this button will do" previews in a three.js / R3F project.4---56# Interactive 3D Object78Turns any mesh/group in an R3F scene into a premium interactive object:9hover → soft ring + glow + cursor; click → ripple + burst + breathing select10ring + floating label; live status tints everything. Three layers, each a11single source of truth:1213```14tokens.js the FEEL — colors, timings, easings; retune the whole system here15status.js the MEANING — pure fn (kind, id, snapshot) → { status, label }16ObjectFX the RENDER — geometry-agnostic overlay, all FX in ONE useFrame17```1819Built for **React Three Fiber 8 + drei 9 + three 0.169**. Copy-paste code in20[REFERENCE.md](REFERENCE.md).2122**Sibling skills this composes with:**23- `r3f-hero-camera` — flies the camera to the object this skill makes selectable.24- `guided-placement` — drag choreography on top of the same hover/select layer.2526## The FX pieces2728All mounted by one `<ObjectFX>` child at the object's footprint center; every29piece is driven by envelope tweens in refs — **zero per-frame React state**.3031| Piece | Behaviour | Reads |32|---|---|---|33| **Hover ring** | thin cool ring, 120ms fade, hidden once selected | `hovered` |34| **Select ring** | expands `0.4→1` with easeOutBack overshoot (250ms), breathes, spins while busy, tints to status | `selected`, `status` |35| **Click ripple** | one-shot expanding ring, re-armed on select rising edge | edge of `selected` |36| **Glow halo** | additive billboard sprite (composer-free bloom stand-in): brightens on hover, blooms on select, breathes while busy | `hovered`, `selected`, `status` |37| **Particle burst** | ~16 points fly out + fall + fade (~600ms) on select | edge of `selected` |38| **Floating label** | drei `Html`, constant screen size, fades/slides in on single-select, `pointerEvents: none` | `selected`, `label` |3940Single vs multi select get different accents (brand color vs blue) so "one" vs41"many" read at a glance.4243## Workflow (checklist)44451. **Copy the three layers** — `tokens.js`, `status.js`, `ObjectFX.jsx`46 (REFERENCE §1–3). Map your domain's states into `STATUS_COLORS` keys47 (RUNNING / WARNING / ALARM / OFFLINE / NEUTRAL) in `status.js`.482. **Wire each object** (REFERENCE §4): pointer handlers on its group49 (`stopPropagation`, cursor swap, hovered `{kind, id}` into shared state),50 `<ObjectFX>` as a child sized from the object's real footprint.513. **Mount ONE `<HoverTooltip>`** at scene level (REFERENCE §5) — intent delay,52 anchor glued to the object every frame, content resolved per kind.534. **Action previews** (optional, REFERENCE §6): HUD buttons spread54 `useActionPreview(intent)`; in-scene consumers telegraph the effect on hover.555. **Tune in tokens only.** If a change needs edits outside `tokens.js`, the56 knob is missing from the token file — add it there.5758## Core principles59601. **Discrete in state, continuous in refs.** React state changes only on61 hover/select/status *edges*; every per-frame value (envelopes, scales,62 opacities) lives in refs inside one `useFrame`. Dragging a slider through63 your scene should show zero re-renders from FX.642. **Envelopes, not booleans.** Each signal drives a 0→1 envelope eased with65 frame-rate-independent damping (`k = 1 − e^(−rate·dt)`, rate derived from the66 token duration). Visuals multiply envelopes — hover ring × `(1 − sel)` hands67 over to the select ring with no pop.683. **One-shots re-arm on rising edges.** Ripple and burst fire when `selected`69 flips false→true (tracked in a ref), never on re-render.704. **Status is a pure function** of a state snapshot — no subscriptions, safe in71 `useFrame`, and every visual (ring, glow, tooltip, label dot) reads the same72 answer. Unknown object → OFFLINE, so a stale id never renders as "running".735. **Geometry-agnostic overlay.** ObjectFX takes `radius` / `labelY` props from74 the object's real footprint — the same component serves a robot, a crate, a75 station. Never bake one object's dimensions in.766. **Tooltip waits for intent.** 200ms delay, re-armed on every hover change —77 flicking the pointer across a crowd shows nothing.787. **Subscribe narrowly.** Each object selects only *its own* hover flag and79 status from the store (`s.hovered?.id === myId`), not the whole hover object80 — or every pointer move re-renders the entire fleet.818. **Previews telegraph, clicks commit.** Hovering a verb shows its effect82 (route, target, ghost); leaving clears it; clicking clears it too — the83 action commits, so a lingering preview is stale UI.8485## Pitfalls8687- **Emissive/FX colors wash out under tone mapping** — `toneMapped={false}` on88 every ring/glow/burst material. And saturated ≠ glowing: real halo = additive89 blending on a radial-gradient sprite (or selective bloom), not a brighter hex.90- **Ground rings z-fight the floor** — lift slightly (+0.01–0.03),91 `depthWrite={false}`, set `renderOrder` if stacked.92- **Html overlays swallow clicks** — every display-only `Html`93 (label, tooltip) needs `pointerEvents: none` or it eats the object's next94 `pointerdown` (often only after first selection — the "second click dies" bug).95- **Clamp dt** (`Math.min(dt, 0.05)`) in the FX `useFrame` — a tab-switch frame96 otherwise teleports every tween to its end.97- **`stopPropagation` on hover/click** or objects behind the pointer also fire;98 restore `document.body.style.cursor` on pointer-out and unmount.99- **Share one glow texture** (module-level CanvasTexture) — one per object leaks100 GPU memory across a fleet.101- **Stable typed arrays for particles** — allocate `Float32Array` once102 (`useMemo`), mutate + `needsUpdate`; recreating the attribute every burst103 reallocates GPU buffers.104- **X-ray/ghosting must clone materials.** GLTF materials are shared across105 clones — mutate one and every instance ghosts. Clone per mesh, stash the106 original in `userData`, restore AND `dispose()` the clone after107 (REFERENCE §7).108- **Cap live `Html` instances.** Labels on select-only (not hover-all) and one109 scene-level tooltip keep the DOM overlay count ~O(selection), not O(objects).110- **Hover state must survive fast object swaps** — key the hover as111 `{kind, id}` in one shared slot; per-object local `useState` strands a stuck112 ring when the pointer jumps between adjacent objects in one frame.