box
The flow box — the first-class SVG UI container built on flow-layout. A
box paints its children (blocks + inline items + wrapped text) to an SVG <g>,
gives itself a background / border, resizes (re-flowing text and inline wrap),
and becomes a scroll region when its content outgrows a fixed height. It
renders identically as a DOM node and — via .el.outerHTML — on a 3D texture
(iconGlyph children bake explicit colours for exactly that), so one
surface serves flat and VR.
This is layout + paint + scroll. The event model (one handlePointer +
focus-traversal for gamepad nav — see UI-DESIGN-NOTES) and the overlay/popup layer
land next; the child protocol already carries the hooks they'll use.
Children
A BoxChild is { el, kind, measure, paint? }:
- block — fills the content width,
measure(width) → { height }, stacks. - inline —
measure() → { width, height }, flows left-to-right and wraps.
Helpers build the common ones: textBlock (wraps text to the width via
widgets3d-layout's real glyph measurer), inlineIcon (an iconGlyph), and
blockItem / inlineItem to drop in any SVG element at a known size.
Demo — the same box in the DOM and on a 3D texture
box.el is an SVG <g>; wrap it in an <svg> to show it flat, or hand it to a
b3dSvgPlane to rasterize it onto a plane. Same object, both surfaces.
import { b3d, b3dLight, b3dSvgPlane, box, textBlock, button } from 'tosijs-3d'
import { svgElements, elements } from 'tosijs'
const { svg } = svgElements
const { div } = elements
const W = 240
const H = 210
const readout = div(
{ style: 'margin:6px 16px 16px;color:#8ea;font:13px system-ui' },
'Activate a button: click it, arrow-key + Enter, or click the 3D panel.'
)
const act = (label) => () => {
readout.textContent = 'Activated: ' + label
}
const makePanel = () => {
const p = box(
{ width: W, height: H, padding: 14, gap: 10, background: '#161a22', border: '#2a3140', radius: 12 },
textBlock('Flow box', { font: { size: 18, weight: 600 }, color: '#e6e6e6' }),
textBlock(
'Blocks stack, text wraps, buttons flow and focus — one surface, DOM and 3D.',
{ font: { size: 13 }, color: '#9fb0c3' }
),
button('Talk', { onActivate: act('Talk') }),
button('Trade', { onActivate: act('Trade') }),
button('Leave', { onActivate: act('Leave') })
)
p.focusMove(1, 0) // focus the first button so the ring shows
return p
}
const sheet = (b) => svg({ viewBox: `0 0 ${W} ${H}`, width: W, height: H }, b.el)
// ONE panel, shown in the DOM AND textured on the plane (SvgTexture clones the
// live element each frame), so DOM and 3D stay in sync — a click or arrow-key in
// either view drives the same box.
const panel = makePanel()
const svgEl = sheet(panel)
svgEl.setAttribute('tabindex', '0')
const toBox = (e) => {
const r = svgEl.getBoundingClientRect()
return [((e.clientX - r.left) / r.width) * W, ((e.clientY - r.top) / r.height) * H]
}
svgEl.addEventListener('pointerdown', (e) => panel.handlePointer('down', ...toBox(e)))
svgEl.addEventListener('pointerup', (e) => panel.handlePointer('up', ...toBox(e)))
svgEl.addEventListener('keydown', (e) => {
const m = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }
if (m[e.key]) { panel.focusMove(...m[e.key]); e.preventDefault() }
else if (e.key === 'Enter' || e.key === ' ') { panel.focusActivate(); e.preventDefault() }
})
// 3D side — route each pick's texture-UV → box coords → the SAME panel's
// handlePointer (the path a VR ray takes), so the 3D view is clickable and synced.
const plane = b3dSvgPlane({
width: 2.4,
height: (2.4 * H) / W,
resolution: 512,
materialChannel: 'emissive',
pointerEvents: false,
})
plane.svgElement = svgEl
const scene = b3d(
{
style: 'width:300px;height:290px;display:block;border-radius:8px;overflow:hidden',
sceneCreated(el) {
const cam = new el.BABYLON.ArcRotateCamera(
'cam', -Math.PI / 2, Math.PI / 2.5, 3.2, el.BABYLON.Vector3.Zero(), el.scene
)
el.setActiveCamera(cam)
cam.attachControl(el.scene.getEngine().getRenderingCanvas(), true)
el.scene.constantlyUpdateMeshUnderPointer = true
const T = el.BABYLON.PointerEventTypes
el.scene.onPointerObservable.add((pi) => {
const kind =
pi.type === T.POINTERDOWN ? 'down' : pi.type === T.POINTERUP ? 'up' : ''
if (!kind) return
const pk = pi.pickInfo
if (pk && pk.hit && pk.pickedMesh === plane.mesh) {
const uv = pk.getTextureCoordinates()
if (uv) panel.handlePointer(kind, uv.x * W, (1 - uv.y) * H)
}
})
},
},
b3dLight({ intensity: 1 }),
plane
)
preview.append(
div(
{ style: 'display:flex;gap:24px;align-items:flex-start;padding:16px 16px 4px;background:#0c0e14' },
div({ style: 'color:#9ab;font:12px system-ui' }, 'DOM — click / arrow-key; 3D mirrors it', svgEl),
div({ style: 'color:#9ab;font:12px system-ui' }, '3D texture — click the buttons', scene)
),
readout
)
Resizable — drag to re-wrap, then scroll
Drag the corner grip: box.resize(w, h) re-flows — the paragraph re-wraps to the
new width (so its height changes), and once the content is taller than the box it
becomes a scroll region (spin the wheel). One box: resize, re-flow, scroll.
import { box, textBlock } from 'tosijs-3d'
import { svgElements, elements } from 'tosijs'
const { svg, rect } = svgElements
const { div } = elements
let W = 320
let H = 170
const long =
'This paragraph re-wraps as the box gets narrower, so its height grows. Shrink the box below the content height and it turns into a scroll region — spin the wheel to scroll. One box: resize, re-flow, and scroll, all in pure SVG.'
const panel = box(
{ width: W, height: H, padding: 14, gap: 8, background: '#161a22', border: '#2a3140', radius: 10 },
textBlock('Resizable box', { font: { size: 16, weight: 600 }, color: '#e6e6e6' }),
textBlock(long, { font: { size: 13 }, color: '#9fb0c3' })
)
const host = svg({ width: 520, height: 340, style: 'touch-action:none' })
host.append(panel.el)
const grip = rect({ width: 16, height: 16, rx: 3, fill: '#5fb0ff', style: 'cursor:nwse-resize' })
host.append(grip)
const placeGrip = () => { grip.setAttribute('x', W - 8); grip.setAttribute('y', panel.viewportHeight - 8) }
placeGrip()
let drag = null
grip.addEventListener('pointerdown', (e) => { drag = { x: e.clientX, y: e.clientY, w: W, h: H }; grip.setPointerCapture(e.pointerId); e.preventDefault() })
grip.addEventListener('pointermove', (e) => {
if (!drag) return
W = Math.max(140, Math.min(500, drag.w + (e.clientX - drag.x)))
H = Math.max(70, Math.min(320, drag.h + (e.clientY - drag.y)))
panel.resize(W, H)
placeGrip()
})
grip.addEventListener('pointerup', () => { drag = null })
host.addEventListener('wheel', (e) => { panel.scrollBy(e.deltaY); e.preventDefault() })
preview.append(
div(
{ style: 'padding:16px;background:#0c0e14' },
host,
div({ style: 'color:#8ea;font:12px system-ui;padding-top:8px' }, 'Drag the blue corner grip; spin the wheel to scroll.')
)
)