The Final Polish - Interactivity, Lighting, and Performance

Learn to elevate your CSS 3D creations by simulating light, adding dynamic JavaScript interactivity with mouse tracking, and ensuring smooth performance with `will-change`.

By Satish Kumar October 25, 2025

We have come an incredible distance. From understanding the theory of a 3D space in our minds, to manipulating planes in 2D, to assembling a complete 3D cube. You now possess the complete architectural skill set for building 3D structures in CSS.

But an architect's job isn't done when the walls are up. The final 10% of the work—the lighting, the interaction, the finishing touches—is what elevates a structure into an experience. This article is about that final polish. We will transform our cube from a static piece of geometry into a dynamic, interactive object that reacts to the user. We will add a layer of realism by simulating light and shadow. And most importantly, we will ensure our creation is buttery smooth by learning the core principles of 3D performance.

Step 1: Simulating Light for Realism

Right now, our cube looks synthetic. Every face has the exact same color, which isn't how objects appear in the real world. The amount of light hitting a surface dictates its appearance. We can create a convincing illusion of a light source by applying different background opacities to each face.

Let's imagine a single light source is positioned above and slightly in front of our cube.

  • The Top face would be hit most directly, making it the brightest.
  • The Front face would be well-lit.
  • The Left & Right faces would be partially lit, appearing slightly darker.
  • The Back & Bottom faces would receive the least light, making them the darkest.

We can achieve this by modifying the bg-cyan-900/30 class on each face. For a dark UI, a lower opacity on a dark background color means less color, making it appear brighter as the page's main background shows through. A higher opacity makes it darker.

Let's apply this logic to our CubeFace components from Article 3:

// in app/page.tsx, update the CubeFace components

// Front face is our baseline
<CubeFace className="[transform:translateZ(96px)] bg-cyan-900/30">Front</CubeFace>

// Back face is darker
<CubeFace className="[transform:rotateY(180deg)_translateZ(96px)] bg-cyan-900/50">Back</CubeFace>

// Right and Left faces are slightly darker
<CubeFace className="[transform:rotateY(90deg)_translateZ(96px)] bg-cyan-900/40">Right</CubeFace>
<CubeFace className="[transform:rotateY(-90deg)_translateZ(96px)] bg-cyan-900/40">Left</CubeFace>

// Top face is brightest (lowest opacity)
<CubeFace className="[transform:rotateX(90deg)_translateZ(96px)] bg-cyan-900/20">Top</CubeFace>

// Bottom face is darkest
<CubeFace className="[transform:rotateX(-90deg)_translateZ(96px)] bg-cyan-900/50">Bottom</CubeFace>
Front
Back
Right
Left
Top
Bottom

This simple, semantic change has a profound impact. The cube immediately feels more physical and grounded in its environment. Your brain's built-in understanding of light and shadow now helps sell the 3D illusion far more effectively.

Step 2: Dynamic Interactivity with JavaScript

Hover animations are a great start, but direct manipulation is far more engaging. We will now make the cube track the user's mouse, giving them the feeling of "holding" and inspecting the object.

To do this, we need to bridge the gap between JavaScript and CSS. We'll use React's state and refs to control the transform style of our cube dynamically.

1. Set up the Component for JS Control

First, this component must be a Client Component in Next.js, as it will use hooks and event listeners. We'll use useRef to get a direct reference to our main scene element and useState to manage the cube's rotation.

// app/page.tsx
'use client'; // This must be the very first line

import { useRef, useState, useEffect, MouseEvent } from 'react';

const CubeFace = ({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) => {
  // ... CubeFace component JSX remains the same
};

export default function Home() {
  const sceneRef = useRef<HTMLDivElement>(null);
  const [rotation, setRotation] = useState({ x: -30, y: 30 });

  // The mouse tracking logic will go here

  return (
    <main
      ref={sceneRef}
      className='h-fit bg-background text-foreground flex justify-center items-center overflow-hidden'
    >
      <div className='group [perspective:1000px]'>
        <div
          className='relative w-48 h-48 [transform-style:preserve-3d]'
          style={{
            transform: `rotateX(${rotation.x}deg) rotateY(${rotation.y}deg)`,
          }}
        >
          {/* ... All 6 shaded CubeFace components from Step 1 ... */}
        </div>
      </div>
    </main>
  );
}

2. Implement the Mouse Tracking Logic

Now, we'll use a useEffect hook to add a mousemove event listener to our main scene. This will calculate the mouse position and update the rotation state, causing the component to re-render with a new transform.

// Continuing in app/page.tsx

// ... inside the Home component, after useState

useEffect(() => {
  const scene = sceneRef.current;
  if (!scene) return;

  const handleMouseMove = (e: globalThis.MouseEvent) => {
    const sceneRect = scene.getBoundingClientRect();
    // Calculate mouse position from the center of the scene
    const mouseX = e.clientX - sceneRect.left - sceneRect.width / 2;
    const mouseY = e.clientY - sceneRect.top - sceneRect.height / 2;

    // A sensitivity factor to control rotation speed
    const rotatePower = 0.2;

    const newRotationY = 30 + mouseX * rotatePower; // Start with initial offset
    const newRotationX = -30 - mouseY * rotatePower; // Start with initial offset

    setRotation({ x: newRotationX, y: newRotationY });
  };

  scene.addEventListener('mousemove', handleMouseMove);

  return () => {
    scene.removeEventListener('mousemove', handleMouseMove);
  };
}, []); // Empty dependency array ensures this runs only once

// ... return JSX remains the same

Now, move your mouse around the page. The cube dynamically follows your cursor, creating a deeply engaging and satisfying interactive experience that CSS alone cannot achieve.

Front
Back
Right
Left
Top
Bottom

Step 3: Performance is a Feature, Not an Afterthought

Our cube might feel smooth now, but as 3D scenes get more complex, performance can suffer. Ensuring a high frame rate is critical to maintaining the illusion.

The Golden Rule: The browser is exceptionally good at animating two properties: transform and opacity.

Animating layout properties like width, height, or margin can trigger expensive "layout" calculations on every frame, leading to stuttering, or "jank." Because our entire 3D methodology is built on the transform property, we are already following this critical best practice.

Giving the Browser a Hint with will-change

We can give the browser an extra performance boost by telling it in advance which properties we plan to animate frequently. This allows the browser to perform optimizations, such as promoting our cube to its own "compositor layer," effectively handing off the animation work to the much more efficient GPU (Graphics Processing Unit).

This is done with the will-change property. It's a simple utility we can add using an arbitrary property in Tailwind.

// Inside the cube wrapper div's className
<div
  className='relative w-48 h-48 [transform-style:preserve-3d] [will-change:transform]'
  style={{ transform: `rotateX(${rotation.x}deg) rotateY(${rotation.y}deg)` }}
>
  {/* ... faces ... */}
</div>

Interactive Playground: The Director's Control Panel

Objective: To let the user feel the impact of fine-tuning animation parameters and the visual effect of shading.

Front
Back
Right
Left
Top
Bottom

Move your mouse over the cube on the left. Notice how shading makes the form easier to read.

'use client';

import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Checkbox } from '@/components/ui/checkbox';

export const CubeFace = ({
  children,
  className,
  style, // <-- 1. ADD THE STYLE PROP HERE
}: {
  children: React.ReactNode;
  className?: string;
  style?: React.CSSProperties; // <-- 2. DEFINE ITS TYPE
}) => {
  return (
    <div
      className={cn(
        'absolute flex items-center justify-center border text-sm font-bold rounded-lg',
        className
      )}
      style={style} // <-- 3. APPLY THE STYLE PROP HERE
    >
      {children}
    </div>
  );
};
/**
 * A fully interactive 3D cube playground.
 * Allows users to control rotation with the mouse, adjust sensitivity, and toggle shading.
 */

export const DirectorPlayground = () => {
  const sceneRef = useRef<HTMLDivElement>(null);
  const [rotation, setRotation] = useState({ x: -30, y: 30 });
  const [sensitivity, setSensitivity] = useState(0.2);
  const [useShading, setUseShading] = useState(true);

  useEffect(() => {
    const scene = sceneRef.current;
    if (!scene) return;

    const handleMouseMove = (e: globalThis.MouseEvent) => {
      const sceneRect = scene.getBoundingClientRect();
      const mouseX = e.clientX - sceneRect.left - sceneRect.width / 2;
      const mouseY = e.clientY - sceneRect.top - sceneRect.height / 2;

      const newRotationY = 30 + mouseX * sensitivity;
      const newRotationX = -30 - mouseY * sensitivity;

      setRotation({ x: newRotationX, y: newRotationY });
    };

    scene.addEventListener('mousemove', handleMouseMove);

    return () => {
      scene.removeEventListener('mousemove', handleMouseMove);
    };
  }, [sensitivity]);

  // THEME CHANGE: Replaced 'bg-accent' with 'bg-cyan-900' for a specific color theme.
  const faceClasses = {
    Front: useShading ? 'bg-cyan-900/30' : 'bg-cyan-900/40',
    Back: useShading ? 'bg-cyan-900/50' : 'bg-cyan-900/40',
    Right: useShading ? 'bg-cyan-900/40' : 'bg-cyan-900/40',
    Left: useShading ? 'bg-cyan-900/40' : 'bg-cyan-900/40',
    Top: useShading ? 'bg-cyan-900/20' : 'bg-cyan-900/40',
    Bottom: useShading ? 'bg-cyan-900/50' : 'bg-cyan-900/40',
  };

  const cubeSize = 144; // w-36, h-36
  const halfSize = cubeSize / 2;

  return (
    <div
      className={cn(
        'w-full rounded-lg border bg-card p-6 grid md:grid-cols-2 gap-8'
      )}
    >
      {/* The 3D Scene */}
      <div
        ref={sceneRef}
        className='w-full h-72 flex items-center justify-center [perspective:1000px]'
      >
        <div
          className='relative [transform-style:preserve-3d] [will-change:transform]'
          style={{
            width: `${cubeSize}px`,
            height: `${cubeSize}px`,
            transform: `rotateX(${rotation.x}deg) rotateY(${rotation.y}deg)`,
          }}
        >
          {/* All six cube faces, with dynamic classes and correct transforms */}
          {/* THEME CHANGE: Added 'text-cyan-300' for consistent text color */}
          <CubeFace
            className={cn('w-36 h-36 text-cyan-300', faceClasses.Front)}
            style={{ transform: `translateZ(${halfSize}px)` }}
          >
            Front
          </CubeFace>
          <CubeFace
            className={cn('w-36 h-36 text-cyan-300', faceClasses.Back)}
            style={{ transform: `rotateY(180deg) translateZ(${halfSize}px)` }}
          >
            Back
          </CubeFace>
          <CubeFace
            className={cn('w-36 h-36 text-cyan-300', faceClasses.Right)}
            style={{ transform: `rotateY(90deg) translateZ(${halfSize}px)` }}
          >
            Right
          </CubeFace>
          <CubeFace
            className={cn('w-36 h-36 text-cyan-300', faceClasses.Left)}
            style={{ transform: `rotateY(-90deg) translateZ(${halfSize}px)` }}
          >
            Left
          </CubeFace>
          <CubeFace
            className={cn('w-36 h-36 text-cyan-300', faceClasses.Top)}
            style={{ transform: `rotateX(90deg) translateZ(${halfSize}px)` }}
          >
            Top
          </CubeFace>
          <CubeFace
            className={cn('w-36 h-36 text-cyan-300', faceClasses.Bottom)}
            style={{ transform: `rotateX(-90deg) translateZ(${halfSize}px)` }}
          >
            Bottom
          </CubeFace>
        </div>
      </div>

      {/* The Controls */}
      <div className='space-y-6 flex flex-col justify-center'>
        <div className='grid gap-2'>
          <Label htmlFor='sensitivity' className='text-muted-foreground'>
            Mouse Sensitivity: {sensitivity.toFixed(2)}
          </Label>
          <Slider
            id='sensitivity'
            min={0.05}
            max={0.5}
            step={0.01}
            value={[sensitivity]}
            onValueChange={([value]) => setSensitivity(value)}
          />
        </div>

        <div className='flex items-center space-x-2'>
          <Checkbox
            id='shading-checkbox'
            checked={useShading}
            onCheckedChange={() => setUseShading(!useShading)}
          />
          <Label htmlFor='shading-checkbox' className='font-medium'>
            Enable Shading
          </Label>
        </div>

        <p className='text-xs text-muted-foreground pt-2'>
          Move your mouse over the cube on the left. Notice how shading makes
          the form easier to read.
        </p>
      </div>
    </div>
  );
};

Key Takeaways

  1. Shading Creates Form: Use color and opacity variations to simulate a light source. This simple trick dramatically increases the perceived realism and volume of your 3D objects.
  2. JavaScript Unlocks True Interactivity: Use event listeners and state management to give users direct, real-time control over 3D objects, making them feel less like animations and more like tools.
  3. Performance is a Feature: Stick to animating transform and opacity. Use will-change: transform as a targeted optimization for complex, continuously animated elements to ensure a smooth experience.

You Have Arrived

This is the culmination of our core skills journey. We have moved from theory to practice, from 2D to 3D, from static to interactive. You have not just learned how to build a cube; you have learned the fundamental principles of creating performant, realistic, and engaging 3D experiences on the web.

The journey doesn't end here. This is your foundation. Your final challenge is to apply these skills to solve real-world problems.


Subscribe to my free Newsletter

Join my weekly newsletter to get the latest updates on design engineering, new projects, and articles I've written. No spam, unsubscribe at any time.