Forum Discussion
3D Cube Interaction
For this build-a-thon, one of the interactions I built was a Cube Rotation Interaction—a custom HTML/CSS/JS block meant to feel a little unexpected.
3D Cube Interaction LayoutLearners can rotate a 3D cube (by dragging or using arrow controls) to move between faces. Each face represents a topic, and selecting one updates an info panel with supporting content and a Complete button. Once completed, a checkmark appears directly on the cube so learners can easily track what they’ve already explored.
The idea came from wanting to build something that doesn’t feel possible in Rise or Storyline. I was brainstorming interactions that really require HTML/CSS, and landed on the idea of rolling a die—something tactile, playful, and naturally three-dimensional. I liked the idea of learners discovering content in any order, rather than clicking through a fixed path.
Cube mid-rotationI used ChatGPT early on to brainstorm and create a build spec before jumping into development. From there, I built and refined the interaction in Antigravity using its code builder (powered by Gemini). It took a fair amount of back-and-forth to get the rotation, visuals, and overall feel right. I also added optional arrow controls for accessibility and used GitHub to track different versions along the way.
A few things I learned:
- Rotating the content instead of the entire cube was important—this helped maintain a sense of orientation.
- Starting with a clear build spec made working with an LLM much more effective.
- Snapping to the nearest face was a needed touch to make things clear and readable.
- Small visual details matter more than expected (like darkening the cube during rotation so the cursor doesn’t get lost).
Course Link:
https://360.articulate.com/review/content/52d258fe-2de4-427a-b04c-747a8970182b/review
I’m sharing the code so others can explore or remix it, and because this was a fun way to push beyond familiar interaction patterns and experiment with something a bit more playful.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flip Cube Interaction</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
/* Scoped variables for easy tuning */
:root {
--cube-size: 300px;
/* Super Slick & Vibrant Palette (Dark Theme Base) */
--bg-grad-start: #0f172a;
--bg-grad-end: #1e293b;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--primary: #6366f1; /* Indigo */
--primary-glow: rgba(99, 102, 241, 0.5);
--accent: #f472b6; /* Pink */
--success: #10b981; /* Emerald */
--success-glow: rgba(16, 185, 129, 0.5);
--panel-bg: rgba(30, 41, 59, 0.7);
--panel-border: 1px solid rgba(255, 255, 255, 0.1);
--panel-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
--face-bg: rgba(255, 255, 255, 0.95);
--face-border: 4px solid #e2e8f0;
--face-border-active: 4px solid var(--primary);
--face-shadow: 0 0 30px rgba(0,0,0,0.1);
--font-main: 'Outfit', sans-serif;
}
body {
font-family: var(--font-main);
background: radial-gradient(circle at top center, var(--bg-grad-start), var(--bg-grad-end));
margin: 0;
padding: 2rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 120px;
color: var(--text-main);
overflow: hidden;
}
/* --- Progress Tracker --- */
.progress-tracker {
position: fixed;
top: 20px;
right: 20px;
background: white;
padding: 12px 24px;
border-radius: 50px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
font-weight: 800;
font-size: 1.2rem;
color: var(--primary);
display: flex;
align-items: center;
gap: 10px;
z-index: 100;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.progress-tracker.complete {
background: var(--accent);
color: white;
transform: scale(1.1);
box-shadow: 0 10px 30px rgba(247, 37, 133, 0.4);
}
.progress-icon {
width: 24px;
height: 24px;
fill: currentColor;
}
/* --- Container Layout --- */
.cube-experience {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
max-width: 1100px;
width: 100%;
/* Glassmorphism Panel Wrapper (optional, currently using grid) */
align-items: center;
}
/* Mobile Layout */
MeDia (max-width: 860px) {
.cube-experience {
grid-template-columns: 1fr;
gap: 60px;
padding: 20px;
}
:root {
--cube-size: 260px;
}
body {
padding: 1rem;
}
}
MeDia (max-width: 400px) {
:root {
--cube-size: 220px;
}
}
/* --- Cube Module --- */
.cube-module {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.cube-stage {
width: var(--cube-size);
height: var(--cube-size);
perspective: 1000px;
cursor: grab;
position: relative;
/* Prevent scrolling on touch drag */
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.cube-stage:active {
cursor: grabbing;
}
.cube {
width: 100%;
height: 100%;
position: absolute;
transform-style: preserve-3d;
/* transform is set by JS */
transition: transform 0ms; /* Initial state, JS handles duration */
will-change: transform;
}
/* --- Animations --- */
@keyframes slideUpFade {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulseGlow {
0% { box-shadow: 0 0 0 0 var(--success-glow); }
70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
/* --- Cube Faces --- */
.face {
position: absolute;
width: var(--cube-size);
height: var(--cube-size);
background: var(--face-bg);
border: var(--face-border);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 24px;
box-sizing: border-box;
border-radius: 24px;
backface-visibility: hidden;
backface-visibility: visible;
box-shadow: var(--face-shadow);
transition: border-color 0.3s, opacity 0.3s; /* Added opacity transition */
}
/* Dynamic Opacity during Interaction */
.cube-stage.interacting .face {
opacity: 0.9;
background: #cbd5e1; /* Light grey for contrast with white cursor */
}
.face.visited {
border: var(--face-border-active);
}
/* Checkmark moved to face-content to rotate with text */
.face.visited .face-content::after {
content: '';
position: absolute;
top: 0px; /* Adjusted for face-content padding/relative pos */
right: 0px;
width: 28px;
height: 28px;
background: var(--success);
border-radius: 50%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
background-size: 18px;
background-repeat: no-repeat;
background-position: center;
animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 4px 10px rgba(76, 201, 240, 0.3);
/* Ensure it sits on top */
z-index: 10;
transform: translate(15px, -15px); /* Offset to corner */
}
@keyframes popIn {
0% { transform: translate(15px, -15px) scale(0); }
100% { transform: translate(15px, -15px) scale(1); }
}
.face h3 {
margin: 0 0 12px 0;
font-size: 1.5rem;
color: var(--primary);
font-weight: 800;
}
.face p {
margin: 0;
font-size: 1rem;
line-height: 1.6;
color: var(--text-muted);
font-weight: 400;
}
.face ul {
text-align: left;
padding-left: 20px;
margin: 0;
font-size: 0.95rem;
color: var(--text-muted);
}
/* Face Specific Transforms will be injected or handled by common class + specific class */
.face--front { transform: rotateY(0deg) translateZ(calc(var(--cube-size)/2)); }
.face--right { transform: rotateY(90deg) translateZ(calc(var(--cube-size)/2)); }
.face--back { transform: rotateY(180deg) translateZ(calc(var(--cube-size)/2)); }
.face--left { transform: rotateY(-90deg) translateZ(calc(var(--cube-size)/2)); }
.face--top { transform: rotateX(90deg) translateZ(calc(var(--cube-size)/2)); }
.face--bottom { transform: rotateX(-90deg) translateZ(calc(var(--cube-size)/2)); }
/* --- Instructions --- */
.instructions {
margin-top: 25px;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8); /* Lighter for accessibility */
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.key-hint {
background: rgba(255,255,255,0.15);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8em;
font-family: monospace;
border: 1px solid rgba(255,255,255,0.3);
color: white;
}
/* --- Info Panel --- */
.panel {
background: var(--panel-bg);
border-radius: 24px;
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border: var(--panel-border);
height: auto;
min-height: 400px;
opacity: 1;
transition: opacity 0.2s ease-out, transform 0.2s ease-out;
box-shadow: var(--panel-shadow);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.panel.fade-out {
opacity: 0;
transform: translateY(10px);
}
.panel-graphic {
background: linear-gradient(135deg, rgb(255, 255, 255) 0%, rgb(255, 255, 255) 100%); /* Subtle glass gradient */
height: 180px;
display: flex;
justify-content: center;
align-items: center;
color: var(--primary);
position: relative;
}
.panel-graphic::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
}
.panel-graphic svg {
width: 90px;
height: 90px;
filter: drop-shadow(0 10px 15px var(--primary-glow));
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
/* Animate icon on entrance */
.panel:not(.fade-out) .panel-graphic svg {
transform: scale(1) translateY(0);
}
.panel.fade-out .panel-graphic svg {
transform: scale(0.8) translateY(10px);
}
.panel-content {
padding: 32px;
}
.panel-label {
text-transform: uppercase;
letter-spacing: 2px;
font-size: 0.7rem;
color: var(--accent);
margin-bottom: 5px;
font-weight: 800;
}
.panel-title {
margin: 0 0 16px 0;
font-size: 2rem;
color: var(--text-main);
font-weight: 800;
letter-spacing: -0.02em;
}
.panel-body {
line-height: 1.8;
color: var(--text-muted);
font-size: 1.05rem;
}
/* Panel Text Animations */
.panel-title, .panel-body, .btn-complete {
transition: transform 0.4s ease-out, opacity 0.4s ease-out;
}
.panel.fade-out .panel-title { transform: translateX(20px); opacity: 0; }
.panel.fade-out .panel-body { transform: translateX(20px); opacity: 0; transition-delay: 0.05s; }
.panel.fade-out .btn-complete { transform: translateY(20px); opacity: 0; transition-delay: 0.1s; }
/* Complete Button */
.btn-complete {
background: var(--primary);
color: white;
border: none;
padding: 12px 24px;
font-size: 1rem;
font-weight: 600;
border-radius: 12px;
cursor: pointer;
margin-top: 24px;
width: 100%;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: var(--font-main);
box-shadow: 0 4px 15px var(--primary-glow);
}
.btn-complete:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 8px 25px var(--primary-glow);
}
.btn-complete:active {
transform: translateY(1px);
}
.btn-complete.completed {
background: var(--success);
cursor: default;
box-shadow: none;
pointer-events: none;
}
.btn-complete.completed:hover {
transform: none;
}
.nav-arrow {
position: absolute;
width: 44px; /* Slightly Larger */
height: 44px;
border-radius: 50%;
/* Glassmorphism */
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
z-index: 10;
}
.nav-arrow:hover {
background: white;
color: black;
transform: scale(1.15);
border-color: white;
box-shadow: 0 8px 25px rgba(255,255,255,0.3);
}
/* Positioning around the cube stage */
/* Since buttons are in cube-module (relative), and stage is centered */
/* We can position absolute relative to the module center, or use margins */
/* Better approach: Wrapper or exact placement relative to stage */
/* Let's place them inside the stage container for easy relative positioning */
.cube-stage .nav-arrow {
position: absolute;
}
.nav-arrow--top {
top: -60px; /* More padding */
left: 50%;
transform: translateX(-50%);
}
.nav-arrow--bottom {
bottom: -60px;
left: 50%;
transform: translateX(-50%);
}
.nav-arrow--left {
top: 50%;
left: -60px;
transform: translateY(-50%);
}
.nav-arrow--right {
top: 50%;
right: -60px;
transform: translateY(-50%);
}
.nav-arrow--top:hover { transform: translateX(-50%) scale(1.15); }
.nav-arrow--bottom:hover { transform: translateX(-50%) scale(1.15); }
.nav-arrow--left:hover { transform: translateY(-50%) scale(1.15); }
.nav-arrow--right:hover { transform: translateY(-50%) scale(1.15); }
/* Toggle Switch */
.toggle-container {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9rem;
color: rgba(255,255,255,0.6); /* Lighter */
margin-left: auto; /* Push to right if in flex container */
padding-top: 30px;
}
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255,255,255,0.2);
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary);
}
input:checked + .slider:before {
transform: translateX(18px);
}
/* Arrow Visibility */
.nav-arrow {
/* ... existing styles ... */
opacity: 0;
pointer-events: none;
/*transform: scale(0.8);*/ /* Conflict with pos transforms, handled below */
}
.cube-stage.show-arrows .nav-arrow {
opacity: 1;
pointer-events: auto;
/*transform: scale(1);*/
}
/* Specific positions for show state (combining with hover scale) */
.cube-stage.show-arrows .nav-arrow--top { transform: translateX(-50%) scale(1); }
.cube-stage.show-arrows .nav-arrow--bottom { transform: translateX(-50%) scale(1); }
.cube-stage.show-arrows .nav-arrow--left { transform: translateY(-50%) scale(1); }
.cube-stage.show-arrows .nav-arrow--right { transform: translateY(-50%) scale(1); }
.cube-stage.show-arrows .nav-arrow--top:hover { transform: translateX(-50%) scale(1.15); }
.cube-stage.show-arrows .nav-arrow--bottom:hover { transform: translateX(-50%) scale(1.15); }
.cube-stage.show-arrows .nav-arrow--left:hover { transform: translateY(-50%) scale(1.15); }
.cube-stage.show-arrows .nav-arrow--right:hover { transform: translateY(-50%) scale(1.15); }
/* Face Content Wrapper */
.face-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
transition: transform 0.4s ease-out;
transform-origin: center center;
backface-visibility: hidden; /* sharpening */
position: relative; /* For checkmark positioning */
}
/* Progress Tracker text fix */
.progress-tracker {
/* ... */
color: var(--primary);
}
#trackerText { color: var(--primary); }
</style>
</style>
</head>
<body>
<div class="cube-experience" aria-label="3D Cube Interaction">
<!-- Left: Cube Module -->
<div class="cube-module">
<div class="cube-stage" id="cubeStage" aria-label="Interactive 3D Cube." tabindex="0">
<div class="cube" id="cube">
<!-- Faces injected via JS or static below -->
<div class="face face--front" data-face="front">
<h3>Front Face</h3>
<p>Start here.</p>
</div>
<div class="face face--right" data-face="right">
<h3>Right Face</h3>
</div>
<div class="face face--back" data-face="back">
<h3>Back Face</h3>
</div>
<div class="face face--left" data-face="left">
<h3>Left Face</h3>
</div>
<div class="face face--top" data-face="top">
<h3>Top Face</h3>
</div>
<div class="face face--bottom" data-face="bottom">
<h3>Bottom Face</h3>
</div>
</div>
<!-- Navigation Arrows -->
<button class="nav-arrow nav-arrow--top" aria-label="Rotate Up">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
</button>
<button class="nav-arrow nav-arrow--right" aria-label="Rotate Right">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</button>
<button class="nav-arrow nav-arrow--bottom" aria-label="Rotate Down">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<button class="nav-arrow nav-arrow--left" aria-label="Rotate Left">
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</button>
</div>
<div class="instructions">
<div class="toggle-container">
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
<span>Drag or use Arrows</span>
<label class="switch">
<input type="checkbox" id="arrowToggle">
<span class="slider"></span>
</label>
<span>Show Arrows</span>
</div>
</div>
</div>
<!-- Right: Graphic Panel -->
<div class="panel" id="infoPanel" aria-live="polite">
<div class="panel-graphic" id="panelGraphic">
<!-- SVG injected here -->
</div>
<div class="panel-content">
<div class="panel-label" id="panelLabel">Face 1 of 6</div>
<h2 class="panel-title" id="panelTitle">Introduction</h2>
<div class="panel-body" id="panelBody">
Explore the cube to reveal different topics.
</div>
<button id="btnComplete" class="btn-complete">
<span>Mark as Read</span>
</button>
</div>
</div>
</div>
<!-- Progress Tracker -->
<div class="progress-tracker" id="progressTracker">
<svg class="progress-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<span id="trackerText">0 / 6</span>
</div>
<!-- Celebration Canvas -->
<canvas id="confettiCanvas" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 999;"></canvas>
</div>
<script>
/**
* Minimal Math Lib
*/
class Quaternion {
constructor(x = 0, y = 0, z = 0, w = 1) { this.x = x; this.y = y; this.z = z; this.w = w; }
static identity() { return new Quaternion(0, 0, 0, 1); }
copy() { return new Quaternion(this.x, this.y, this.z, this.w); }
setFromAxisAngle(axis, angleRad) {
const halfAngle = angleRad / 2;
const s = Math.sin(halfAngle);
this.x = axis.x * s;
this.y = axis.y * s;
this.z = axis.z * s;
this.w = Math.cos(halfAngle);
return this;
}
multiply(q) {
const qax = this.x, qay = this.y, qaz = this.z, qaw = this.w;
const qbx = q.x, qby = q.y, qbz = q.z, qbw = q.w;
this.x = qax * qbw + qaw * qbx + qay * qbz - qaz * qby;
this.y = qay * qbw + qaw * qby + qaz * qbx - qax * qbz;
this.z = qaz * qbw + qaw * qbz + qax * qby - qay * qbx;
this.w = qaw * qbw - qax * qbx - qay * qby - qaz * qbz;
return this;
}
premultiply(q) {
const qax = q.x, qay = q.y, qaz = q.z, qaw = q.w;
const qbx = this.x, qby = this.y, qbz = this.z, qbw = this.w;
this.x = qax * qbw + qaw * qbx + qay * qbz - qaz * qby;
this.y = qay * qbw + qaw * qby + qaz * qbx - qax * qbz;
this.z = qaz * qbw + qaw * qbz + qax * qby - qay * qbx;
this.w = qaw * qbw - qax * qbx - qay * qby - qaz * qbz;
return this;
}
normalize() {
let l = Math.sqrt(this.x*this.x + this.y*this.y + this.z*this.z + this.w*this.w);
if (l === 0) { this.x=0; this.y=0; this.z=0; this.w=1; }
else { l = 1/l; this.x*=l; this.y*=l; this.z*=l; this.w*=l; }
return this;
}
dot(q) {
return this.x*q.x + this.y*q.y + this.z*q.z + this.w*q.w;
}
slerp(qb, t) {
if (t === 0) return this;
if (t === 1) return this.copy().set(qb.x, qb.y, qb.z, qb.w);
let x = this.x, y = this.y, z = this.z, w = this.w;
// Calculate cosine
let cosHalfTheta = w * qb.w + x * qb.x + y * qb.y + z * qb.z;
// If negative, invert one to take shortest path
if (cosHalfTheta < 0) {
this.w = -qb.w; this.x = -qb.x; this.y = -qb.y; this.z = -qb.z;
cosHalfTheta = -cosHalfTheta;
} else {
this.copyFrom(qb);
}
// If parallel, linear interp
if (cosHalfTheta >= 1.0) {
this.w = w; this.x = x; this.y = y; this.z = z;
return this;
}
const sinHalfTheta = Math.sqrt(1.0 - cosHalfTheta * cosHalfTheta);
// Avoid division by zero
if (Math.abs(sinHalfTheta) < 0.001) {
this.w = 0.5 * (w + this.w);
this.x = 0.5 * (x + this.x);
this.y = 0.5 * (y + this.y);
this.z = 0.5 * (z + this.z);
return this.normalize();
}
const halfTheta = Math.atan2(sinHalfTheta, cosHalfTheta);
const ratioA = Math.sin((1 - t) * halfTheta) / sinHalfTheta;
const ratioB = Math.sin(t * halfTheta) / sinHalfTheta;
this.w = (w * ratioA + this.w * ratioB);
this.x = (x * ratioA + this.x * ratioB);
this.y = (y * ratioA + this.y * ratioB);
this.z = (z * ratioA + this.z * ratioB);
return this;
}
copyFrom(q) { this.x=q.x; this.y=q.y; this.z=q.z; this.w=q.w; return this; }
set(x,y,z,w) { this.x=x; this.y=y; this.z=z; this.w=w; return this; }
static slerpFlat(qa, qb, t) {
return qa.copy().slerp(qb, t);
}
toMatrix3D() {
const x = this.x, y = this.y, z = this.z, w = this.w;
const x2 = x + x, y2 = y + y, z2 = z + z;
const xx = x * x2, xy = x * y2, xz = x * z2;
const yy = y * y2, yz = y * z2, zz = z * z2;
const wx = w * x2, wy = w * y2, wz = w * z2;
return `matrix3d(
${1 - (yy + zz)}, ${xy + wz}, ${xz - wy}, 0,
${xy - wz}, ${1 - (xx + zz)}, ${yz + wx}, 0,
${xz + wy}, ${yz - wx}, ${1 - (xx + yy)}, 0,
0, 0, 0, 1
)`;
}
}
/**
* Configuration & Data
*/
const CONFIG = {
sensitivity: 0.005, // Radians per pixel
friction: 0.92, // Inertia decay
snapThreshold: 0.001 // Velocity threshold
};
// Calculate Base Quaternions for each face (UPRIGHT)
// Front: Id
// Right: Ry(-90)
// Back: Ry(180)
// Left: Ry(90)
// Top: Rx(90)
// Bottom: Rx(-90)
function createQ(axis, angleDeg) {
return new Quaternion().setFromAxisAngle(axis, angleDeg * Math.PI / 180);
}
const FACES = {
front: {
id: 'front', label: '1 of 6',
cubeContent: `<h3>Introduction</h3><p>Welcome to the 3D learning module.</p>`,
panel: { title: "Introduction", body: "This is the starting point. The front face represents the primary topic or introduction to the module.", icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>` },
baseQ: Quaternion.identity()
},
right: {
id: 'right', label: '2 of 6',
cubeContent: `<h3>Analysis</h3><p>Key metrics and data points.</p>`,
panel: { title: "Analysis", body: "Here we dive into the numbers. Understanding the metrics is crucial for evaluating performance.", icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>` },
baseQ: createQ({x:0, y:1, z:0}, -90)
},
back: {
id: 'back', label: '3 of 6',
cubeContent: `<h3>Methodology</h3><ul><li>Step 1: Plan</li><li>Step 2: Execute</li></ul>`,
panel: { title: "Methodology", body: "A systematic approach ensures consistent results. We follow a strict two-phase methodology.", icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>` },
baseQ: createQ({x:0, y:1, z:0}, 180)
},
left: {
id: 'left', label: '4 of 6',
cubeContent: `<h3>History</h3><p>Looking back at origins.</p>`,
panel: { title: "History", body: "Knowing where we came from helps us understand where we are going.", icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>` },
baseQ: createQ({x:0, y:1, z:0}, 90)
},
top: {
id: 'top', label: '5 of 6',
cubeContent: `<h3>Vision</h3><p>High-level goals.</p>`,
panel: { title: "Vision", body: "The top-down view allows us to see the bigger picture and long-term strategic goals.", icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>` },
baseQ: createQ({x:1, y:0, z:0}, -90)
},
bottom: {
id: 'bottom', label: '6 of 6',
cubeContent: `<h3>Foundation</h3><p>Underlying support.</p>`,
panel: { title: "Foundation", body: "Every great structure needs a solid base. These are the fundamental principles.", icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18"/><path d="M5 21v-7"/><path d="M19 21v-7"/><path d="M9 9l3-6 3 6"/><path d="M9 9h6v12H9z"/></svg>` },
baseQ: createQ({x:1, y:0, z:0}, 90)
}
};
/**
* State
*/
let state = {
qCurrent: Quaternion.identity(),
isDragging: false,
startX: 0,
startY: 0,
currentFace: 'front',
vy: 0,
lastMoveTime: 0,
isSnapping: false,
visitedFaces: new Set()
};
/**
* Elements
*/
const cube = document.getElementById('cube');
const stage = document.getElementById('cubeStage');
const panel = document.getElementById('infoPanel');
const panelGraphic = document.getElementById('panelGraphic');
const panelLabel = document.getElementById('panelLabel');
const panelTitle = document.getElementById('panelTitle');
const panelBody = document.getElementById('panelBody');
const btnComplete = document.getElementById('btnComplete');
const trackerText = document.getElementById('trackerText');
const progressTracker = document.getElementById('progressTracker');
/**
* Initialization
*/
function init() {
// Hydrate cube faces
document.querySelectorAll('.face').forEach(faceEl => {
const id = faceEl.getAttribute('data-face');
if (FACES[id]) {
faceEl.innerHTML = `<div class="face-content">${FACES[id].cubeContent}</div>`;
}
});
updatePanel('front', true);
// Initial Transform
updateCubeTransform();
// Events
stage.addEventListener('mousedown', onPointerDown);
stage.addEventListener('touchstart', onPointerDown, { passive: false });
// Window Listeners
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
window.addEventListener('touchmove', onPointerMove, { passive: false });
window.addEventListener('touchend', onPointerUp);
stage.addEventListener('keydown', onKeyDown);
// Nav Buttons
document.querySelector('.nav-arrow--top').addEventListener('click', (e) => { e.stopPropagation(); triggerRelativeRotate(0, -1); }); // Up (Simulate Drag Up)
document.querySelector('.nav-arrow--bottom').addEventListener('click', (e) => { e.stopPropagation(); triggerRelativeRotate(0, 1); }); // Down (Simulate Drag Down)
document.querySelector('.nav-arrow--left').addEventListener('click', (e) => { e.stopPropagation(); triggerRelativeRotate(-1, 0); }); // Left (Simulate Drag Left)
document.querySelector('.nav-arrow--right').addEventListener('click', (e) => { e.stopPropagation(); triggerRelativeRotate(1, 0); }); // Right (Simulate Drag Right)
// Toggle Arrows
const toggle = document.getElementById('arrowToggle');
toggle.addEventListener('change', (e) => {
stage.classList.toggle('show-arrows', e.target.checked);
});
document.querySelectorAll('.nav-arrow').forEach(btn => {
btn.addEventListener('mousedown', (e) => e.stopPropagation());
btn.addEventListener('touchstart', (e) => e.stopPropagation());
});
// Complete Button
const btnComplete = document.getElementById('btnComplete');
btnComplete.addEventListener('click', onCompleteClick);
requestAnimationFrame(animate);
}
/**
* Interaction Logic
*/
function onPointerDown(e) {
state.isSnapping = false;
state.isDragging = true;
state.vx = 0;
state.vy = 0;
stage.classList.add('interacting'); // Trigger opacity change
const { x, y } = getCoord(e);
state.startX = x;
state.startY = y;
e.preventDefault();
document.body.style.cursor = 'grabbing';
stage.style.cursor = 'grabbing';
}
function onPointerMove(e) {
if (!state.isDragging) return;
if(e.type === 'touchmove') e.preventDefault();
const { x, y } = getCoord(e);
const dx = x - state.startX;
const dy = y - state.startY;
state.vx = dx;
state.vy = dy;
state.startX = x;
state.startY = y;
state.lastMoveTime = performance.now();
applyRotation(dx, dy);
}
function onPointerUp(e) {
if (!state.isDragging) return;
state.isDragging = false;
document.body.style.cursor = '';
stage.style.cursor = 'grab';
// If user held still for > 100ms, kill velocity
const timeSinceMove = performance.now() - state.lastMoveTime;
if (timeSinceMove > 100) {
state.vx = 0;
state.vy = 0;
}
startInertia();
}
function getCoord(e) {
if (e.touches && e.touches.length > 0) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
return { x: e.clientX, y: e.clientY };
}
function applyRotation(dx, dy) {
// Screen Space Rotation
const angleY = dx * CONFIG.sensitivity;
const angleX = -dy * CONFIG.sensitivity;
const qRotY = new Quaternion().setFromAxisAngle({x:0,y:1,z:0}, angleY);
const qRotX = new Quaternion().setFromAxisAngle({x:1,y:0,z:0}, angleX);
// Apply Screen Rotations
state.qCurrent.premultiply(qRotX);
state.qCurrent.premultiply(qRotY);
state.qCurrent.normalize();
updateCubeTransform();
}
/**
* Physics & Animation
*/
function animate() {
requestAnimationFrame(animate);
}
function startInertia() {
if(state.isSnapping) return;
function inertiaLoop() {
if (state.isDragging || state.isSnapping) return;
state.vx *= CONFIG.friction;
state.vy *= CONFIG.friction;
if (Math.abs(state.vx) < 1 && Math.abs(state.vy) < 1) {
snapToNearestFace();
return;
}
applyRotation(state.vx, state.vy);
requestAnimationFrame(inertiaLoop);
}
inertiaLoop();
}
function getBestMatch(q) {
// Z-Rolls: 0, 90, 180, 270 (Degrees)
const rolls = [0, 90, 180, 270];
const axisZ = {x:0, y:0, z:1};
let bestDist = -1; // Cosine similarity: 1 is identical, -1 opposite.
let bestFaceId = 'front';
let bestTargetQ = null;
let bestRoll = 0;
// Iterate all 24 states
for (const [key, data] of Object.entries(FACES)) {
const baseQ = data.baseQ;
for (let r of rolls) {
const rollQ = new Quaternion().setFromAxisAngle(axisZ, r * Math.PI / 180);
// Note: Premultiply rollQ because it's a global view rotation
const candidateQ = rollQ.multiply(baseQ.copy());
// Dot product |dot| closer to 1 means closer orientation
let dot = Math.abs(q.dot(candidateQ));
if (dot > bestDist) {
bestDist = dot;
bestFaceId = key;
// Snap to the ROLLED state, not the base state
bestTargetQ = candidateQ;
bestRoll = r;
}
}
}
return { targetQ: bestTargetQ, faceId: bestFaceId, roll: bestRoll };
}
function snapToNearestFace() {
state.isSnapping = true;
// Remove interacting class to restore full opacity
stage.classList.remove('interacting');
const match = getBestMatch(state.qCurrent);
animateSnap(match.targetQ, match.faceId, match.roll);
}
function animateSnap(qTarget, faceId, roll = 0) {
const qStart = state.qCurrent.copy();
const startTime = performance.now();
const duration = 500;
// Ensure shortest path for slerp
if (qStart.dot(qTarget) < 0) {
qTarget.set(-qTarget.x, -qTarget.y, -qTarget.z, -qTarget.w);
}
// Apply counter-rotation to text immediately so it looks right as it settles
rotateFaceContent(faceId, roll);
function loop(now) {
if (state.isDragging) return;
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
state.qCurrent = Quaternion.slerpFlat(qStart, qTarget, ease);
updateCubeTransform();
if (t < 1) {
requestAnimationFrame(loop);
} else {
state.isSnapping = false;
state.qCurrent.copyFrom(qTarget);
updateCubeTransform();
if (state.currentFace !== faceId) {
updatePanel(faceId);
state.currentFace = faceId;
}
}
}
requestAnimationFrame(loop);
}
function rotateFaceContent(faceId, roll) {
const faceEl = document.querySelector(`.face[data-face="${faceId}"] .face-content`);
if (faceEl) {
// Counter-rotate text to keep it upright
// If cube is rolled +90 (CW), text needs -90 (CCW)
faceEl.style.transform = `rotate(${-roll}deg)`;
}
}
function triggerRelativeRotate(dxDir, dyDir) {
if (state.isSnapping || state.isDragging) return;
const rotationAmount = Math.PI / 2;
let axis, angle;
// Global Rotation Axes
if (dxDir !== 0) {
axis = {x:0, y:1, z:0}; // Screen Y
// Invert logic: Left Arrow (dx=-1) -> Show Left (+90 deg)
angle = -dxDir * rotationAmount;
} else {
axis = {x:1, y:0, z:0}; // Screen X
// Invert logic: Top Arrow (dy=-1) -> Show Top (+90 deg)
angle = -dyDir * rotationAmount;
}
const qRot = new Quaternion().setFromAxisAngle(axis, angle);
// Apply Global Rotation: New = Rot * Old
const qTargetProposed = state.qCurrent.copy().premultiply(qRot);
// Calculate best match based on proposed target
const match = getBestMatch(qTargetProposed);
animateSnap(match.targetQ, match.faceId, match.roll);
}
function updateCubeTransform() {
const mat = state.qCurrent.toMatrix3D();
cube.style.transform = mat;
}
function updatePanel(faceId, instant = false) {
const data = FACES[faceId];
if (!data) return;
if (instant) {
renderPanelContent(data);
return;
}
panel.classList.add('fade-out');
setTimeout(() => {
renderPanelContent(data);
panel.classList.remove('fade-out');
}, 200);
}
function renderPanelContent(data) {
panelLabel.textContent = data.label;
panelTitle.textContent = data.panel.title;
panelBody.textContent = data.panel.body;
panelGraphic.innerHTML = data.panel.icon;
// Update Button State
const isVisited = state.visitedFaces.has(data.id);
if (isVisited) {
btnComplete.classList.add('completed');
btnComplete.innerHTML = `<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg> Completed`;
} else {
btnComplete.classList.remove('completed');
btnComplete.textContent = "Mark as Read";
}
}
function onCompleteClick() {
const currentId = state.currentFace;
if (state.visitedFaces.has(currentId)) return;
// Mark as visited
state.visitedFaces.add(currentId);
// Add visual indicator to cube face
const faceEl = document.querySelector(`.face[data-face="${currentId}"]`);
if (faceEl) faceEl.classList.add('visited');
// Update Tracker
trackerText.textContent = `${state.visitedFaces.size} / 6`;
// Update Button State (Immediate)
btnComplete.classList.add('completed');
btnComplete.innerHTML = `<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg> Completed`;
// Celebration Check
if (state.visitedFaces.size === 6) {
triggerCelebration();
}
}
function triggerCelebration() {
const tracker = document.getElementById('progressTracker');
tracker.classList.add('complete');
// Simple Confetti
const canvas = document.getElementById('confettiCanvas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const pieces = [];
const colors = ['#f72585', '#4361ee', '#4cc9f0', '#7209b7', '#ffd166'];
for(let i=0; i<300; i++) {
pieces.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height - canvas.height,
w: Math.random() * 10 + 5,
h: Math.random() * 10 + 5,
color: colors[Math.floor(Math.random() * colors.length)],
vy: Math.random() * 5 + 2,
vx: Math.random() * 4 - 2,
rot: Math.random() * 360,
rotSpeed: Math.random() * 10 - 5
});
}
function loop() {
ctx.clearRect(0,0,canvas.width,canvas.height);
let active = false;
pieces.forEach(p => {
p.y += p.vy;
p.x += p.vx;
p.rot += p.rotSpeed;
if (p.y < canvas.height) active = true;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot * Math.PI / 180);
ctx.fillStyle = p.color;
ctx.fillRect(-p.w/2, -p.h/2, p.w, p.h);
ctx.restore();
});
if (active) requestAnimationFrame(loop);
}
loop();
}
function onKeyDown(e) {
let handled = false;
if (e.key === 'ArrowLeft') { triggerRelativeRotate(-1, 0); handled=true; }
else if (e.key === 'ArrowRight') { triggerRelativeRotate(1, 0); handled=true; }
else if (e.key === 'ArrowUp') { triggerRelativeRotate(0, -1); handled=true; }
else if (e.key === 'ArrowDown') { triggerRelativeRotate(0, 1); handled=true; }
if(handled) e.preventDefault();
}
init();
</script>
</body>
</html>
2 Replies
- Thomas_ShayonCommunity Member
I loved the fluid movement of the cube. Nice work.
- ArthaLearning01Community Member
Thanks so much :) I had a fun time tweaking the movement and visuals!
Related Content
- 2 days ago