Nifty Studio — 3D, Motion & Technical Graphics

3D & motion for complex product storytelling.

We make products, systems and stories easier to see, understand and sell. Product CGI, mechanism animation, launch visuals and doc graphics built from CAD, references and messy real-world inputs.

0+
Products visualized
0+
Industries
CAD
Native pipeline
L01 RENDER
Scroll
Reels

Two quick cuts: product/CGI work and doc graphics/story work.

STUDIO REEL / PRODUCT · CGI · LAUNCH
Selected clients

Selected work

A quick scan of recent work. Product visuals, technical explainers and doc graphics in one place.

Capabilities

One studio for the whole storytelling stack around a product.

Process

A tight pipeline from your engineering data to delivery-ready assets.

Studio note

Small team.Senior hands.Based in Denver.

Nifty is a small 3D and motion studio based in Denver.

We help teams explain products, systems and stories that are hard to show with normal production. Sometimes that starts with CAD. Sometimes it starts with a rough sketch, a documentary timeline, a map, or a messy technical explanation.

The job is the same either way: make the thing clear enough that people get it faster.

Contact
Let's talk

Send us the thing you need to explain.

A part, an assembly, a mechanism, a map, a timeline, a whole system. If it is hard to show clearly, that is exactly our work.

Book a call
/* What We Make — cards, video reveal, side panel / drawer */ const MAKE=[ {n:'01',t:'Product CGI',d:'Photoreal product renders, stills and looping videos built straight from CAD and reference.',good:['Ecomm','Launch','Ads'],vid:'https://player.vimeo.com/video/1154764877?background=1&autoplay=1&loop=1&muted=1&byline=0&title=0&portrait=0'}, {n:'02',t:'Technical Animation',d:'Motion that shows how a product performs, adapts and responds in real conditions.',good:['Function','Performance','Sales'],vid:'https://player.vimeo.com/video/844724293?background=1&autoplay=1&loop=1&muted=1&byline=0&title=0&portrait=0'}, {n:'03',t:'Mechanism Explainers',d:'Cutaways and exploded views that make complex internal systems easy to read.',good:['How it works','Patents','Training'],vid:'https://player.vimeo.com/video/1154822525?background=1&autoplay=1&loop=1&muted=1&byline=0&title=0&portrait=0'}, {n:'04',t:'Launch Visuals',d:'Hero films and campaign moments that introduce a product before it physically exists.',good:['Reveal','Campaign','Hype'],vid:'https://player.vimeo.com/video/1154779482?background=1&autoplay=1&loop=1&muted=1&byline=0&title=0&portrait=0'}, {n:'05',t:'Doc Graphics',d:'Maps, timelines and evidence graphics that organize real-world information for film.',good:['Docs','Maps','Timelines'],vid:'https://player.vimeo.com/video/1053183910?background=1&autoplay=1&loop=1&muted=1&byline=0&title=0&portrait=0'}, {n:'06',t:'Reusable Asset Kits',d:'Modular libraries of looping visuals you can deploy across product pages and channels.',good:['Ecomm','Scale','Systems'],vid:'https://player.vimeo.com/video/769233427?background=1&autoplay=1&loop=1&muted=1&byline=0&title=0&portrait=0'}, ]; (function(){ const grid=document.getElementById('makeGrid'); const side=document.getElementById('makeSide'); const panel=document.getElementById('sidePanel'); if(!grid)return; const isTouch=matchMedia('(hover:none),(pointer:coarse)').matches; MAKE.forEach(m=>{ const cell=document.createElement('div'); cell.className='make-cell'; cell.dataset.n=m.n; cell.tabIndex=0; cell.setAttribute('role','button'); cell.setAttribute('aria-label',m.t+' — '+m.d); cell.innerHTML=`
[${m.n}]

${esc(m.t)}

${esc(m.d)}

`; if(isTouch){ const drawer=document.createElement('div'); drawer.className='make-drawer'; drawer.innerHTML=`Capability ${m.n}

${esc(m.t)}

${esc(m.d)}

Good for
${m.good.map(g=>''+esc(g)+'').join('')}
`; grid.appendChild(cell);grid.appendChild(drawer); cell.addEventListener('click',()=>{ const open=drawer.classList.contains('open'); grid.querySelectorAll('.make-drawer.open').forEach(d=>d.classList.remove('open')); grid.querySelectorAll('.make-cell.vid-live').forEach(c=>c.classList.remove('vid-live')); if(!open){drawer.classList.add('open');cell.classList.add('vid-live');} }); }else{ grid.appendChild(cell); } }); function fillPanel(m){ panel.querySelector('.sp-eyebrow').textContent='Capability '+m.n; panel.querySelector('h4').textContent=m.t; panel.querySelector('p').textContent=m.d; panel.querySelector('.sp-tags').innerHTML=m.good.map(g=>''+esc(g)+'').join(''); } if(!isTouch){ const cells=[...grid.querySelectorAll('.make-cell')]; let warmed=false; const warm=new IntersectionObserver((es)=>{ es.forEach(e=>{if(e.isIntersecting&&!warmed){warmed=true;/* iframes already loading; nothing else needed */}}); },{threshold:.15}); warm.observe(grid); const layout=grid.closest('.make-layout'); function setActiveCell(cell){cells.forEach(c=>c.classList.toggle('is-active',c===cell));} let curY=null, rafY=null; function panelH(){return side.getBoundingClientRect().height||panel.getBoundingClientRect().height||180;} /* clamp the panel's top within the layout, following the hovered card's Y (eased) */ function targetYForCell(cell){ const lr=layout.getBoundingClientRect(); const cr=cell.getBoundingClientRect(); const ph=panelH(); const pad=8; /* clearance from layout top/bottom */ const cardMidRel=(cr.top-lr.top)+cr.height/2; let y=cardMidRel-ph/2; /* center panel on the card's Y */ const maxY=lr.height-ph-pad; y=Math.max(pad,Math.min(y,Math.max(pad,maxY))); return y; } function easeToY(y){ if(curY===null){curY=y;side.style.setProperty('--side-y',y+'px');return;} cancelAnimationFrame(rafY); const from=curY, to=y, t0=performance.now(), dur=460; const ease=t=>1-Math.pow(1-t,3); (function step(now){ const k=Math.min(1,(now-t0)/dur); curY=from+(to-from)*ease(k); side.style.setProperty('--side-y',curY+'px'); if(k<1)rafY=requestAnimationFrame(step); })(t0); } /* Reveal a card: clear the blur/tint (via CSS state), sync the side panel, mark active */ function revealCell(cell,m){ setActiveCell(cell); if(panel.dataset.cur!==m.n){ panel.classList.add('swapping'); setTimeout(()=>{fillPanel(m);panel.dataset.cur=m.n;panel.classList.remove('swapping');},140); } side.classList.add('show'); side.setAttribute('aria-hidden','false'); easeToY(targetYForCell(cell)); } cells.forEach((cell,i)=>{ const m=MAKE[i]; cell.addEventListener('mouseenter',()=>revealCell(cell,m)); /* Keyboard focus mirrors hover so the reveal works without a pointer */ cell.addEventListener('focus',()=>revealCell(cell,m)); cell.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){e.preventDefault();revealCell(cell,m);} }); }); grid.addEventListener('mouseleave',()=>{ side.classList.remove('show'); side.setAttribute('aria-hidden','true'); panel.dataset.cur=''; cells.forEach(c=>c.classList.remove('is-active')); }); /* Drop active state when focus leaves the grid entirely */ grid.addEventListener('focusout',e=>{ if(!grid.contains(e.relatedTarget)){ cells.forEach(c=>c.classList.remove('is-active')); } }); if(MAKE[0])fillPanel(MAKE[0]); /* end make hover wiring */ } })(); /* How We Work — timeline with measured centerline + mograph jump */ const FLOW=[ {n:'01',t:'Input & scope',d:'CAD, sketches, references or a messy brief. We figure out what actually needs to be clear.'}, {n:'02',t:'Previz & blocking',d:'Rough camera, timing and layout so we agree on the story before polish.'}, {n:'03',t:'Build & animate',d:'Modeling, look-dev and animation. The real craft happens here.'}, {n:'04',t:'Deliver & reuse',d:'Final assets in the formats you need, built to reuse across channels.'}, ]; (function(){ const flow=document.getElementById('flowGrid'); if(!flow)return; FLOW.forEach(s=>{ const step=document.createElement('div'); step.className='step'; step.innerHTML=`${s.n}

${esc(s.t)}

${esc(s.d)}

`; flow.appendChild(step); }); const steps=[...flow.querySelectorAll('.step')]; const reduce=()=>matchMedia('(prefers-reduced-motion:reduce)').matches; const SVGNS='http://www.w3.org/2000/svg'; /* ===== SVG overlay: visible rail + invisible motion path + one runner circle ===== Everything lives in ONE SVG whose userSpace pixels match .flow's own pixels (viewBox="0 0 W H" with the SVG sized to fill .flow). Bullet centers are measured relative to .flow's border box, which is exactly the SVG's coordinate origin, so rail, path and runner share one coordinate system with the bullets as source of truth. */ const svg=document.createElementNS(SVGNS,'svg'); svg.setAttribute('class','flow-svg'); svg.setAttribute('preserveAspectRatio','none'); svg.innerHTML= ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''; flow.appendChild(svg); const railBg=svg.querySelector('.flow-rail-bg'); const railFill=svg.querySelector('.flow-rail-fill'); const motion=svg.querySelector('.flow-motion'); const runner=svg.querySelector('.flow-runner'); const halo=svg.querySelector('.flow-runner-halo'); /* Measured geometry, in .flow border-box pixels (== SVG user units). */ let NX=[]; /* four bullet-center X's */ let RAILY=0; /* shared bullet-center Y */ let ARC=140; /* return-arc crest height (upward) */ let motionLen=0; function measureFlow(){ const fr=flow.getBoundingClientRect(); if(fr.width===0)return false; /* Use the SVG's own bounding rect as the coordinate origin — because the SVG is position:absolute;inset:0 its top-left should match .flow's, but the SVG is appended as a grid child which can shift .flow's measured rect. Using the SVG rect directly means bullet offsets are always in the same coordinate space as the SVG userSpace, eliminating any grid-gap or row-offset discrepancy. */ const sr=svg.getBoundingClientRect(); if(sr.width===0)return false; const bullets=steps.map(st=>st.querySelector('.node').getBoundingClientRect()); NX=bullets.map(nr=>(nr.left-sr.left)+nr.width/2); RAILY=(bullets[0].top-sr.top)+bullets[0].height/2; /* Size the SVG userSpace to the SVG's own pixel dimensions so 1 user-unit = 1px. */ svg.setAttribute('viewBox','0 0 '+sr.width+' '+sr.height); /* Visible rail through the four bullet centers. */ const x1=NX[0], x2=NX[NX.length-1]; [railBg,railFill].forEach(l=>{ l.setAttribute('x1',x1);l.setAttribute('y1',RAILY); l.setAttribute('x2',x2);l.setAttribute('y2',RAILY); }); const railLen=Math.abs(x2-x1); flow.style.setProperty('--rail-len',railLen); /* Responsive arc height, kept inside the section. */ const w=innerWidth; ARC=w<=600?55:(w<=1024?82:105); /* Motion path: straight through 01->02->03->04, then a single smooth cubic-bezier arc that leaves node04 diagonally up-left, crests high, and lands cleanly on node01. */ const y=RAILY; let d='M '+NX[0]+' '+y; for(let i=1;ist.classList.remove('hit'),560); } /* Place the runner at a normalized progress p (0..1) along the measured motion path. Progress is mapped so the 3 travel legs + hold + return each get an eased share of the loop, giving a polished character-ball feel without discrete straight segments. */ const easeInOut=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2; const easeRetX=t=>1-Math.pow(1-t,2.2); /* Fraction of total path length occupied by the straight travel (01->04). */ let travelFrac=0.5; function computeFracs(){ const travelPx=Math.abs(NX[NX.length-1]-NX[0]); travelFrac=motionLen>0?travelPx/motionLen:0.5; } const T_LEG=1.05, T_HOLD=0.30, T_RET=1.30; let started=false, running=false, raf=0, t0=0, hitState=[]; function positionAt(len){ const pt=motion.getPointAtLength(Math.max(0,Math.min(motionLen,len))); runner.setAttribute('cx',pt.x);runner.setAttribute('cy',pt.y); halo.setAttribute('cx',pt.x);halo.setAttribute('cy',pt.y); } function frame(now){ const legs=NX.length-1; /* 3 */ const travelDur=legs*T_LEG; const total=travelDur+T_HOLD+T_RET; let t=((now-t0)/1000)%total; let len; if(t=i*T_LEG-0.02){hitState[i]=true;hitStep(i);} } }else if(t=0.999)hitState=[]; } positionAt(len); raf=requestAnimationFrame(frame); } function startLoop(){ if(running)return; if(!measureFlow())return; computeFracs(); flow.classList.add('drawn'); positionAt(0); if(reduce()){ hitStep(0); running=true; return; } running=true;hitState=[];t0=performance.now(); cancelAnimationFrame(raf); raf=requestAnimationFrame(frame); } let rzT=null; addEventListener('resize',()=>{ clearTimeout(rzT); rzT=setTimeout(()=>{ if(started){measureFlow();computeFracs();} },140); },{passive:true}); if(document.fonts&&document.fonts.ready){document.fonts.ready.then(()=>{if(started){measureFlow();computeFracs();}});} const fio=new IntersectionObserver((es)=>{ es.forEach(e=>{ if(e.isIntersecting&&!started){ started=true; requestAnimationFrame(()=>{ measureFlow(); requestAnimationFrame(()=>{measureFlow();setTimeout(startLoop,60);}); }); } }); },{threshold:.2}); fio.observe(flow); setTimeout(()=>{if(!started){started=true;measureFlow();startLoop();}},1400); })(); /* ===== Studio Note — locator / targeting interface ===== On scroll into view: short delay, then crosshair H+V lines "search" the frame while coordinates flicker and map-scale ticks drift; the crosshairs converge on a final lock point (slightly left of center, upper third), a cyan target dot appears + pulses, the upper-right info block reveals, and the readout settles on the Denver coordinates. Total ~3.6s. Reduced motion jumps straight to the settled state. */ (function(){ const loc=document.getElementById('snLocator'); if(!loc)return; const svg=document.getElementById('locSvg'); const H=document.getElementById('locH'); const V=document.getElementById('locV'); const target=document.getElementById('locTarget'); const info=document.getElementById('locInfo'); const coord=document.getElementById('locCoord'); const ticks=document.getElementById('locTicks'); const VB={w:300,h:280}; /* Final lock: lower-left of centre so the info stack has room above/right of the dot. */ const LOCK={x:VB.w*0.445,y:VB.h*0.54}; /* Base Denver coordinates — exact resting values the readout settles/returns to. */ const BASE_LAT=39.7392, BASE_LON=104.9903; const FINAL_COORD=BASE_LAT.toFixed(4)+'° N
'+BASE_LON.toFixed(4)+'° W'; const reduce=matchMedia('(prefers-reduced-motion:reduce)').matches; /* Format the two-line coordinate readout for a given lat/lon. */ function fmtCoord(lat,lon){return lat.toFixed(4)+'° N
'+lon.toFixed(4)+'° W';} /* place() works in viewBox units; crosshair lines live in the (non-uniform) SVG, the dot/info live in the real box, positioned as % so they stay a true circle + aligned. */ function place(x,y){ H.setAttribute('y1',y);H.setAttribute('y2',y); V.setAttribute('x1',x);V.setAttribute('x2',x); const px=(x/VB.w)*100, py=(y/VB.h)*100; target.style.left=px+'%';target.style.top=py+'%'; } /* Anchor the info stack just above-and-right of the dot, left edge aligned to the dot X. */ function placeInfo(){ info.style.left=((LOCK.x/VB.w)*100)+'%'; info.style.top=''; info.style.bottom=(100-((LOCK.y/VB.h)*100))+'%'; info.style.marginLeft='14px'; info.style.marginBottom='22px'; } /* place() with an optional pull offset (viewBox units). The crosshair intersection, dot and info stack all shift by the SAME offset so the target group moves as one. */ function placeInfoOffset(dx,dy){ info.style.left=(((LOCK.x+dx)/VB.w)*100)+'%'; info.style.top=''; info.style.bottom=(100-(((LOCK.y+dy)/VB.h)*100))+'%'; info.style.marginLeft='14px'; info.style.marginBottom='22px'; } function placeGroup(dx,dy){ place(LOCK.x+dx,LOCK.y+dy); placeInfoOffset(dx,dy); } function settle(){ place(LOCK.x,LOCK.y); placeInfo(); coord.innerHTML=FINAL_COORD; loc.classList.add('armed','locked','reveal-info'); /* stagger the info lines in — flatten all .li items (including those inside .loc-services) */ const infoLines=[...info.querySelectorAll('.li')]; infoLines.forEach((el,i)=>{el.style.transitionDelay=(0.12+i*0.08)+'s';el.style.opacity='1';el.style.transform='none'}); if(ticks)ticks.style.transform='none'; enableGravityPull(); } /* ===== Subtle hover "gravity pull" — only after the locator has settled ===== The whole target group (dot + ring + crosshair intersection + info stack) eases toward the cursor by a dampened, clamped offset. On leave it eases back to lock. Transform-only geometry via placeGroup(); touch + reduced-motion skip entirely. */ let pullOn=false, pullRaf=0; let curDx=0, curDy=0, tgtDx=0, tgtDy=0; /* Movement caps in viewBox units: ~20% of width, ~13% of height. */ const MAXX=VB.w*0.20, MAXY=VB.h*0.13; /* Max coordinate drift at full deflection: keep it small + believable (<= 0.0010). */ const LAT_SWING=0.0010, LON_SWING=0.0010; const isTouch=matchMedia('(hover:none),(pointer:coarse)').matches; /* Update the readout from the CURRENT eased target offset. Moving the target down (dy+) nudges latitude down; moving right/east (dx+) nudges longitude (° W) down. Normalized by the movement caps so full deflection == full (small) swing. */ function updateCoordFromOffset(){ const nx=Math.max(-1,Math.min(1,curDx/MAXX)); const ny=Math.max(-1,Math.min(1,curDy/MAXY)); const lat=BASE_LAT - ny*LAT_SWING; const lon=BASE_LON - nx*LON_SWING; coord.innerHTML=fmtCoord(lat,lon); } function pullTick(){ curDx+=(tgtDx-curDx)*0.09; curDy+=(tgtDy-curDy)*0.09; if(Math.abs(tgtDx-curDx)<0.05&&Math.abs(tgtDy-curDy)<0.05){ curDx=tgtDx;curDy=tgtDy; placeGroup(curDx,curDy); updateCoordFromOffset(); pullRaf=0; return; } placeGroup(curDx,curDy); updateCoordFromOffset(); pullRaf=requestAnimationFrame(pullTick); } function requestPull(){ if(!pullRaf)pullRaf=requestAnimationFrame(pullTick); } function enableGravityPull(){ if(pullOn||isTouch||reduce)return; pullOn=true; /* Dampening: cursor offset from lock (in box %) -> a gentle fraction of the way there. */ const PULL=0.34; /* Activate anywhere inside the FULL Studio Note card, but always compute the pointer offset from the LOCATOR box so the target group stays constrained to the right column. */ const card=document.getElementById('studio-note')||loc; card.addEventListener('pointermove',e=>{ if(e.pointerType==='touch')return; const r=loc.getBoundingClientRect(); if(!r.width||!r.height)return; /* pointer position -> locator viewBox units (can fall outside 0..VB when over left copy) */ const pxVB=((e.clientX-r.left)/r.width)*VB.w; const pyVB=((e.clientY-r.top)/r.height)*VB.h; let dx=(pxVB-LOCK.x)*PULL; let dy=(pyVB-LOCK.y)*PULL; dx=Math.max(-MAXX,Math.min(MAXX,dx)); dy=Math.max(-MAXY,Math.min(MAXY,dy)); tgtDx=dx;tgtDy=dy; requestPull(); }); card.addEventListener('pointerleave',()=>{ tgtDx=0;tgtDy=0;requestPull(); }); } let started=false,raf=0; function run(){ if(started)return;started=true; placeInfo(); if(reduce){settle();return;} /* longer pause so the viewer registers the card before motion begins */ setTimeout(()=>{ loc.classList.add('armed'); const t0=performance.now(); const DUR=3400; /* search phase before lock */ /* two overlapping "search" oscillations that decay toward the lock point */ function frame(now){ const t=Math.min(1,(now-t0)/DUR); const ease=1-Math.pow(1-t,2.2); /* converge, decelerating */ const decay=Math.pow(1-t,1.5); /* oscillation amplitude fades out */ const ax=(VB.w*0.30)*decay, ay=(VB.h*0.24)*decay; const x=LOCK.x + Math.sin(now*0.0052)*ax*(1-ease*0.6); const y=LOCK.y + Math.cos(now*0.0041+1.1)*ay*(1-ease*0.6); place(x,y); /* coordinate flicker — rolls while searching */ const rLat=(39+Math.random()*1.6).toFixed(4); const rLon=(104+Math.random()*1.6).toFixed(4); coord.innerHTML=rLat+'° N
'+rLon+'° W'; /* map-scale drift */ if(ticks)ticks.style.transform='translateX('+(Math.sin(now*0.004)*10*decay)+'px)'; if(t<1){raf=requestAnimationFrame(frame);} else{ cancelAnimationFrame(raf); /* smooth final glide onto the exact lock point */ const g0=performance.now(),gx=parseFloat(V.getAttribute('x1')),gy=parseFloat(H.getAttribute('y1')); (function glide(gn){ const k=Math.min(1,(gn-g0)/380),e=1-Math.pow(1-k,3); place(gx+(LOCK.x-gx)*e,gy+(LOCK.y-gy)*e); if(k<1)requestAnimationFrame(glide); else settle(); })(g0); } } raf=requestAnimationFrame(frame); },1150); } const lio=new IntersectionObserver((es)=>{ es.forEach(e=>{if(e.isIntersecting){run();lio.unobserve(loc);}}); },{threshold:.35}); lio.observe(loc); setTimeout(()=>{if(!started)run();},4200); })(); /* Copy email */ function bindCopy(btn){ if(!btn)return; btn.addEventListener('click',async()=>{ const email=btn.dataset.email; try{await navigator.clipboard.writeText(email);} catch(e){const ta=document.createElement('textarea');ta.value=email;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(_){}ta.remove();} btn.classList.add('copied'); setTimeout(()=>btn.classList.remove('copied'),1600); }); } bindCopy(document.getElementById('copyEmail')); bindCopy(document.getElementById('copyEmailIcon')); /* Smooth in-page nav — About scrolls to the Studio Note card with comfortable negative space above it (scroll-margin-top on the card keeps it clear of the header). */ document.querySelectorAll('a[href^="#"]').forEach(a=>{ a.addEventListener('click',e=>{ if(a.dataset.handled==='1')return; /* work-filter links manage their own scroll+state */ const id=a.getAttribute('href'); if(id.length<2)return; const t=document.querySelector(id); if(!t)return; e.preventDefault(); t.scrollIntoView({behavior:'smooth',block:'start'}); }); });