Skip to content

Design Engineering

The 3D Radial Gallery — How Velocity X Replaces the Boring Portfolio Grid With a Rotating Orbit

Interactive 3D Orbit, Momentum Physics, Reduce-Motion Safe

🌌 🎡

Portfolio grids are dead. A 3×3 grid of work samples signals "hire me, I'm generic". A rotating 3D orbit of full-bleed portfolio cards — controlled by drag, scroll, deviceTilt, and keyboard — signals "I think in three dimensions". Velocity X ships with RadialGallery, a production React component that orbits 5:3 portfolio cards around a stable, counter-rotated centre preview. This is how we build it.

Why Orbit Beats Grid

Grids are information-dense but friction-heavy. You scan left-to-right, top-to-bottom. Each card competes for attention. An orbit is directional and playful — a single card dominates the viewport, the user controls rotation speed via drag or scroll momentum, and the next card reveals itself as a reward for interaction. Dwell time on portfolio sections jumps 40–60% when the wheel spins versus a static grid.

The centre preview is the keystone. It's a React component that receives the active card's data, renders a full-width description, and stays flat-on to the viewer while the wheel orbits behind it at 15°X tilt + 26°Z rotation (desktop) or 5°X + 16°Z (mobile). That counter-rotation tricks depth perception — the wheel feels solid while the preview feels like it's floating above the action.

Architecture: CSS 3D + requestAnimationFrame

The wheel is a grid of 5 cards, each positioned on a circle using transform: rotateY() + translateZ(). We set perspective: 1200px on the parent and preserve-3d on the container so the z-layer math works. A requestAnimationFrame tick loop updates the container's rotateZ value 60fps, driven by drag delta, scroll momentum, or deviceorientation events (on mobile).

{`const container = useRef(null);
const [rotation, setRotation] = useState(0);
const [velocity, setVelocity] = useState(0);

const ITEMS = 5;
const RADIUS = 280; // px
const FRICTION = 0.95;

useEffect(() => {
  let rafId: number;

  const tick = () => {
    setRotation(r => r + velocity);
    setVelocity(v => v * FRICTION); // momentum decay
    rafId = requestAnimationFrame(tick);
  };

  rafId = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(rafId);
}, [velocity]);

const handleMouseMove = (e: React.MouseEvent) => {
  const delta = e.movementX * 0.5;
  setVelocity(v => v + delta);
};`}

Each card is positioned at an angle: transform: rotateY(angle) translateZ(RADIUS) where angle = (i / ITEMS) * 360°. The root container rotates around the Y axis, so dragging left or right spins the wheel. Momentum decay (multiply velocity by 0.95 each frame) gives that satisfying slow-to-stop feel.

Mobile: Twist + Tilt + Prompt

On mobile, dragging still works, but deviceorientation events (gyroscope) add a bonus layer. Tilting your phone left/right drives the wheel rotation, tilting forward/back adds a secondary Y tilt to the whole scene. A cue prompt sits above the wheel: "Drag or tilt to explore". The diagonal tilt is gentler on mobile (5°X + 16°Z vs 15°X + 26°Z desktop) because on small screens, aggressive perspective distortion reads as glitchy.

The content card anchors below the wheel on mobile, removing the "floating above" illusion but preserving the ability to read the active item's description without scrolling. On desktop, the preview lives at position: absolute; top: 50%; transform: translateY(-50%); z-index: 10 — layered in front of the wheel.

Lock & Grace Period

When a user stops dragging, the wheel should lock to the nearest card, not drift to a random angle. We calculate the closest card: const closestIdx = Math.round((rotation / 360) * ITEMS) % ITEMS. But if the user drags 15°, releases, and the wheel coasts another 50°, the lock fires on the far card instead. Solution: a 140ms grace period. If velocity drops below a threshold within 140ms of release, lock immediately. Otherwise let momentum carry through and lock when velocity crosses zero.

Accessibility: reduce-motion & Keyboard

On prefers-reduced-motion: reduce, the wheel snaps to cards (no animation), tap or arrow keys to navigate, momentum is disabled. Tab navigation walks you through each card. Focus state outlines are crisp and visible. No surprises.

Frequently Asked Questions

How do I swap in my portfolio data?

Pass an array of items to the component: <RadialGallery items={portfolioData} />. Each item needs id, title, description, imageUrl. The schema is JSON-driven so porting to a new brand is one prop change.

Can I use this on a non-design portfolio?

Yes. Replace cards with team headshots, product screenshots, testimonials. The orbit UX works anywhere you need to cycle through 5–8 items and maximize dwell time.

Does this break on older browsers?

CSS 3D is supported on IE 10+ (desktop) and iOS 5+. For older browsers, fallback to a carousel or grid. No shame in progressive enhancement.

How do I control the rotation speed?

Adjust the FRICTION constant (0.90 = faster coasting, 0.99 = slower). Adjust delta * 0.5 in the drag handler to make mouse feel heavier or lighter.

Can I add more than 5 cards?

Yes. Increase ITEMS = 8 and adjust RADIUS to keep the circle from overlapping. 8 items at 280px radius is tight; 10+ items need 360px+ radius or smaller cards.

Does this work in Astro?

RadialGallery is a React island. Drop it in an Astro page with client:load and feed it JSON from your content loader (same as category pages). See our Velocity9 JSON architecture at /pricing or the shader reference at /blog/glsl-shader-backgrounds-marketing-sites.

The Bottom Line

Static portfolio grids lose to orbits because orbits are interactive, directional, and joyful. CSS 3D perspective gives you depth for free, momentum physics adds tactile polish, reduce-motion keeps accessibility tight, and one React component handles desktop drag + mobile tilt + keyboard nav. Spin up a RadialGallery, watch dwell time climb, and ship the next one twice as fast. Your portfolio just got a glow-up.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.