Rubik Cube

An interactive 3D Rubik's cube component with multiple dimensions, visual modes, and smooth drag-to-rotate animations.

Install dependencies

npm i framer-motion

Utility function

Create a file lib/utils.ts and paste this code

import { ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }

Component

Create a file rubik-cube.tsx in your components folder and paste this code

'use client'; import React, { useEffect, useState } from 'react'; import { motion, useMotionValue, useTransform, animate, useAnimationControls } from 'framer-motion'; import { cn } from '@/lib/utils'; import { Button } from '../ui/button'; const SIZE = 48; const modes = ['skeleton', 'normal', 'glass']; const dimensionModes = [ { mode: '2x2', center: [] }, { mode: '3x3', center: [4] }, { mode: '4x4', center: [5, 6, 9, 10] } ]; const Piece = ({ config, index, mode, isCenterPiece }: { config: { color: string; position: string }; index: number; mode: number; isCenterPiece: boolean; }) => { // Enhanced glass mode colors with better combinations const getGlassColor = (baseColor: string, index: number) => { const colors = [ 'rgba(255, 255, 255, 0.15)', // White glass 'rgba(173, 216, 230, 0.2)', // Light blue glass 'rgba(240, 248, 255, 0.18)', // Alice blue glass 'rgba(230, 230, 250, 0.16)', // Lavender glass 'rgba(255, 250, 240, 0.17)', // Floral white glass 'rgba(245, 245, 245, 0.19)', // White smoke glass 'rgba(248, 248, 255, 0.14)', // Ghost white glass 'rgba(250, 235, 215, 0.15)', // Antique white glass 'rgba(255, 245, 238, 0.16)' // Seashell glass ]; return colors[index % colors.length]; }; return ( <motion.div style={{ height: SIZE, width: SIZE }} animate={{ backgroundColor: mode === 2 ? getGlassColor(config.color, index) : mode === 0 ? 'rgba(0, 0, 0, 0)' : config.color, filter: 'none', borderColor: mode === 0 ? index % 2 === 0 ? 'rgba(220, 220, 220, 1)' : 'rgba(200, 200, 200, 1)' : mode === 2 ? 'rgba(255, 255, 235, 1)' : '#000000', backdropFilter: mode === 2 ? 'blur(4px)' : 'none' }} transition={{ duration: 0.3, ease: 'linear', delay: index * 0.05 }} className={cn('border-[1.5px] relative shrink-0', isCenterPiece && 'z-10')} > {isCenterPiece && mode !== 0 && ( <> <div className="absolute top-0 left-0 -translate-x-[calc(50%+2px)] -translate-y-[calc(50%+2px)] rotate-45 w-3 h-3 bg-black z-[1000]" /> <div className="absolute top-0 right-0 translate-x-[calc(50%+2px)] -translate-y-[calc(50%+2px)] rotate-45 w-3 h-3 bg-black z-[1000]" /> <div className="absolute bottom-0 right-0 translate-x-[calc(50%+2px)] translate-y-[calc(50%+2px)] rotate-45 w-3 h-3 bg-black z-[1000]" /> <div className="absolute bottom-0 left-0 -translate-x-[calc(50%+2px)] translate-y-[calc(50%+2px)] rotate-45 w-3 h-3 bg-black z-[1000]" /> </> )} </motion.div> ); }; interface FaceProps { config: { color: string; position: string; }; mode: number; dimensionMode: number; } const Face = ({ config, mode, dimensionMode }: FaceProps) => { const calculatedPosition = { rotateX: 0, rotateY: 0, translateY: 0, translateZ: 0, translateX: 0 }; const DISPLACEMENT_MULTIPLIER = dimensionMode === 0 ? 1 : dimensionMode === 1 ? 1.5 : 2; if (config.position === 'top') { calculatedPosition.rotateX = 90; calculatedPosition.translateY = -SIZE * DISPLACEMENT_MULTIPLIER; } else if (config.position === 'back') { calculatedPosition.translateZ = SIZE * DISPLACEMENT_MULTIPLIER; } else if (config.position === 'front') { calculatedPosition.translateZ = -SIZE * DISPLACEMENT_MULTIPLIER; } else if (config.position === 'bottom') { calculatedPosition.translateY = SIZE * DISPLACEMENT_MULTIPLIER; calculatedPosition.rotateX = -90; } else if (config.position === 'right') { calculatedPosition.rotateY = 90; calculatedPosition.translateX = SIZE * DISPLACEMENT_MULTIPLIER; } else if (config.position === 'left') { calculatedPosition.rotateY = -90; calculatedPosition.translateX = -SIZE * DISPLACEMENT_MULTIPLIER; } return ( <motion.div style={{ rotateX: calculatedPosition.rotateX, rotateY: calculatedPosition.rotateY, translateX: calculatedPosition.translateX, translateY: calculatedPosition.translateY, translateZ: calculatedPosition.translateZ, transformStyle: 'preserve-3d', backfaceVisibility: 'visible' }} className={cn( 'grid absolute shrink-0', dimensionMode === 0 ? 'grid-cols-2' : dimensionMode === 1 ? 'grid-cols-3' : 'grid-cols-4' )} > {Array.from({ length: dimensionMode === 0 ? 4 : dimensionMode === 1 ? 9 : 16 }).map( (_, index) => { return ( <Piece key={'face' + config.position + index} config={config} index={index} isCenterPiece={dimensionModes[dimensionMode].center.includes(index)} mode={mode} ></Piece> ); } )} </motion.div> ); }; const RubikCube = ({ mode = 'normal', dimensionMode = '3x3' }: { mode?: 'skeleton' | 'normal' | 'glass'; dimensionMode?: '2x2' | '3x3' | '4x4'; }) => { const controls = useAnimationControls(); const faceColors = [ { color: '#C41E3A', position: 'left' }, { color: '#FF5800', position: 'right' }, { color: '#009E60', position: 'top' }, { color: '#0051BA', position: 'bottom' }, { color: '#FFD500', position: 'front' }, { color: '#ffffff', position: 'back' } ]; // Motion values for interactive rotation const rotateX = useMotionValue(45); const rotateY = useMotionValue(45); // Transform mouse drag to rotation const dragRotateX = useTransform(rotateX, (value) => value); const dragRotateY = useTransform(rotateY, (value) => value); const handleDragEnd = async () => { // Animate back to starting position await Promise.all([ animate(rotateX, 45, { duration: 0.8, ease: 'easeOut' }), animate(rotateY, 45, { duration: 0.8, ease: 'easeOut' }) ]); controls.start({ rotateX: [45, 405], rotateY: [45, -405], transition: { duration: 10, ease: 'linear', repeat: Infinity, repeatType: 'loop' } }); }; useEffect(() => { handleDragEnd(); }, []); const modeIndex = modes.indexOf(mode); const dimensionModeIndex = dimensionModes.findIndex((mode) => mode.mode === dimensionMode); return ( <div className="flex items-center justify-center relative"> <motion.div style={{ height: SIZE * (dimensionModeIndex === 0 ? 2 : dimensionModeIndex === 1 ? 3 : 4), width: SIZE * (dimensionModeIndex === 0 ? 2 : dimensionModeIndex === 1 ? 3 : 4), perspective: '10000px', transformStyle: 'preserve-3d', rotateX: dragRotateX, rotateY: dragRotateY, cursor: 'grab' }} animate={controls} transition={{ duration: 0.8, ease: 'easeOut' }} drag dragConstraints={{ left: -0, right: 0, top: -0, bottom: 0 }} dragElastic={0.1} dragSnapToOrigin={true} onDrag={(event, info) => { // Map drag movement to rotation const sensitivity = 0.5; rotateY.set(rotateY.get() + info.delta.x * sensitivity); rotateX.set(rotateX.get() - info.delta.y * sensitivity); }} onDragStart={() => { controls.stop(); }} onDragEnd={handleDragEnd} className="relative flex flex-wrap items-center justify-center" > {faceColors.map((config, index) => { // calculate when the face is in view return ( <Face key={'face' + index} config={config} mode={modeIndex} dimensionMode={dimensionModeIndex} /> ); })} </motion.div> </div> ); }; export default RubikCube;

Usage

<RubikCube dimensionMode="2x2" mode="glass" /> <RubikCube /> <RubikCube dimensionMode="4x4" mode="skeleton" />