Transform Composition — Rotate, Scale & Offset

Every card below rotates and/or scales an element and then pushes it with an offset (data-foldpage-offset-* or CSS translateZ/translate*). Each card names the expected result before you look — compare what you see against the description. Best viewed at desktop width; rotate your view (drag) to see the 3D relationships clearly.

Core behavior under test: an element's offset (however it's authored) is applied inside its own rotation/scale — so offsetting a rotated element pushes it along its own rotated face normal, not the world/screen axes. Rotate a card 30° and push its Z, and it should visibly slide diagonally in the direction it's already facing — not straight out of the screen.

1. DOM Elements — Rotate + Offset (data attributes)

Plain <div> boxes, positioned purely with data-foldpage-rotate-* and data-foldpage-offset-*. No CSS transform involved.

Baseline — no rotation

Control case. Offset-z with zero rotation always pushes straight toward the viewer — world space and local space are identical here.

BaselineFRONT
offset-z="60"

rotate-y 30° + offset-z

Tilted right edge back. The push should slide the box diagonally toward its own (rotated) front face — down-and-right of straight-out, not straight out.

rotateY 30°FRONT
rotate-y="30" offset-z="60"

rotate-y -30° + offset-z

Mirror of the previous card. Same push magnitude, opposite tilt — should look like an exact mirror image, confirming the push direction always tracks the element's own rotation.

rotateY -30°FRONT
rotate-y="-30" offset-z="60"

rotate-x 25° + offset-z

Tipped forward around X instead of Y. The push should slide up/down along the tilt (not sideways) — the reorientation happens on whichever axis is actually rotated.

rotateX 25°FRONT
rotate-x="25" offset-z="60"

Combined rotate-x + rotate-y + offset-z

Diagonal tilt on two axes at once. The push follows the single combined face normal — a diagonal slide, not two separate movements.

rotate 15/30/0FRONT
rotate="15 30 0" (shorthand: x y z) offset-z="60"

rotate-y 40° + offset-x

Offsetting along local X on a Y-rotated element. Because rotateY tilts the X/Z plane, this push should visibly move both sideways and toward/away from the camera — not purely sideways.

rotateY 40°FRONT
rotate-y="40" offset-x="60"

rotate-z 20° + offset-y

A roll (rotate-z) tilts the X/Y plane. The vertical push should come out slightly diagonal, following the rolled local "up" direction instead of straight up.

rotateZ 20°FRONT
rotate-z="20" offset-y="-50"

2. Rotation Origin / Pivot + Offset

data-foldpage-rotate-origin moves the rotation pivot away from the element's center. The offset is applied after that pivoted rotation, so it should still track the element's own (now off-center-rotated) orientation.

Default origin (center) — reference

Rotates in place around its own center. Included only as a visual reference for the next card.

center pivot
rotate-y="40" (no rotate-origin)

Origin shifted to left edge — "door hinge"

Same rotation, but pivoting around the box's left edge instead of its center — swings like a door on a hinge rather than spinning in place.

hinge pivot
rotate-y="40" rotate-origin="-75 0 0" (left edge of a 150px box)

Hinge pivot + offset-z

Combines the previous card with a Z push. The push should follow the face normal of the swung box (post-pivot-rotation), not the box's original unrotated orientation.

hinge + push
rotate-y="40" rotate-origin="-75 0 0" offset-z="50"

Hinge pivot, opposite rotation

Same hinge point, rotated the other way — the box should swing toward the viewer instead of away, and the offset push should follow suit.

hinge + push
rotate-y="-40" rotate-origin="-75 0 0" offset-z="50"

3. Scale Composed with Rotation + Offset

Scale wraps rotation which wraps the local offset (see FoldGroupElement's group structure) — so a scaled element's offset distance scales with it too.

Worth knowing: this is a deliberate simplification, not a bug. Offset is nested inside the scale transform, so offset-z="40" on a scale="1.5" element renders as if it were pushed ~60 world-units — 1.5× the authored value.

scale 1.5 + rotate-y 30° — reference

No offset yet — just confirms the box renders 1.5× larger and still rotates correctly around its own (now larger) center.

scale 1.5×
scale="1.5" rotate-y="30"

scale 1.5 + rotate-y 30° + offset-z 40

Same as above plus a Z push. Compare the apparent push distance against the 1.0-scale card in Section 1 with the same offset-z="60" value (30° card) — this one should look similarly far despite authoring only 40, because the 1.5× scale amplifies it.

scale 1.5×
scale="1.5" rotate-y="30" offset-z="40" → effective push ≈ 60

scale 0.6 + rotate-y -30° + offset-z 40

Same authored offset as the card to the left, but scaled down. The push should look noticeably shorter (~24 effective units) — the amplification runs both directions.

scale 0.6×
scale="0.6" rotate-y="-30" offset-z="40" → effective push ≈ 24

scale-origin off-center + rotate-y

Scaling from the left edge instead of the center — the box should grow/rotate outward from that edge rather than swelling symmetrically.

edge scale
scale="1.4" scale-origin="-75 0 0" rotate-y="20"

4. Nested DOM Elements

A parent box carries the rotation; a small child badge sits inside it. Three.js scene-graph nesting means the child inherits the parent's rotation automatically — these cards test whether the child's own offset/rotation additionally composes correctly on top of that.

Parent rotated, plain nested child

Child badge has no transform of its own — it should simply sit in place on the parent's face and tilt with the parent, at zero relative offset.

parent
child
parent: rotate-y="25" child: (no attributes)

Parent rotated, child has its own offset-z

The child's offset-z should push it further along the parent's already-rotated face normal — the same local-frame composition as Section 1, just one nesting level deeper.

parent
child +z
parent: rotate-y="25" child: offset-z="40"

Parent rotated, child has its own additional rotate-y

Rotations should add up: the child tilts by its own 20° on top of the parent's 25°, for a combined ~45° lean relative to the page — not 20° measured from flat.

parent 25°
child +20°
parent: rotate-y="25" child: rotate-y="20" (relative)

Parent hinge-pivoted, child inherits the full chain

Parent uses the Section 2 "door hinge" pivot; the child has its own small offset. The child should ride along with the parent's swing and still push along its own local axis on top of that.

hinge parent
child +z
parent: rotate-y="35" rotate-origin="-75 0 0" child: offset-z="30"

5. CSS transform + Hover Transitions

Same composition, driven by CSS transform (parsed from getComputedStyle()) instead of data attributes — including the live hover transition path. Hover each card.

rotateY(30deg) → hover adds translateZ(60px)

On hover, the box should lift toward the viewer along its own tilt — sliding diagonally, matching the data-attribute version in Section 1.

hover me
rest: transform: rotateY(30deg); hover: transform: rotateY(30deg) translateZ(60px);

rotateY(-30deg) → hover adds translateZ(60px)

Mirror of the previous card — should hover in the mirrored diagonal direction, at the same magnitude.

hover me
rest: transform: rotateY(-30deg); hover: transform: rotateY(-30deg) translateZ(60px);

Function order A: rotateY() translateZ()

Authored with rotate listed first. Should look identical to the card on the right.

order A
hover: transform: rotateY(30deg) translateZ(60px);

Function order B: translateZ() rotateY()

Authored with translate listed first — in raw CSS this ordering has different math meaning (translate happens in the pre-rotation frame). Fold can't see that distinction: getComputedStyle() always resolves transform to a single matrix before Fold reads it, so authoring order is invisible to the engine. Both cards render identically here by design.

order B
hover: transform: translateZ(60px) rotateY(30deg);

6. GLB Models — Rotate, Scale & Offset

data-foldpage-model-src hosts go through the exact same FoldGroupElement composition as any other element — these confirm the fix holds for 3D geometry, not just flat CSS planes.

Monstera — rotate-y + offset-z

Rotated 35° and pushed forward. The model should slide toward the camera along the direction it's already facing, matching the flat-box cards in Section 1.

model-src="monstera_tree-transformed.glb" rotate-y="35" offset-z="60"

Plant — scale + rotate + offset (amplification)

Same scale-composition consequence from Section 3, on a real model: scaled 1.6×, the authored offset-z="30" should appear to push roughly 1.6× as far as it would unscaled.

model-src="plant-transformed.glb" scale="1.6" rotate-y="-25" offset-z="30"

Indoor plant — rotate-origin hinge pivot

Same "door hinge" pivot from Section 2, applied to a model instead of a flat box — it should swing around its own left edge rather than spinning in place.

model-src="indoor_plant_with_pot_a-transformed.glb" rotate-y="30" rotate-origin="-90 0 0"