Stack TowerA column of 12 stacked words that reads as a rotating 3D cylinder — 2D transforms skew, scale and shift each row so the rotation travels down the stack.
TypographyHero sectionType specimenPortfolio
Switch to light
Refresh
Full screen
STACK
TOWER
STACK
TOWER
STACK
TOWER
STACK
TOWER
STACK
TOWER
STACK
TOWER
Loading source…
Add to your project
One command adds this component to your project.
1
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/stack-tower
2
For dark mode, add the dark class to your <html> element:
Optional
<html class="dark">
Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for one inside Claude Code, Codex, or Cursor and it installs the component you pick. Works with any AI Canvas account, free or premium.
Stack Tower is a column of twelve stacked words that reads as a rotating 3D cylinder, with 2D transforms skewing, scaling, and shifting each row so the rotation visibly travels down the stack. Hover any row and it lights up in warm orange without breaking the rhythm of the rotation. The whole effect is pure Motion plus Tailwind, no real 3D library required, which keeps the bundle small. It is built for hero sections, type specimens, and portfolio splash screens that want depth without WebGL.
Built with
MotionTailwind CSS
Frequently asked questions
Is Stack Tower free to use?
Yes. Stack Tower is part of the free AI Canvas library and is open source under the MIT license, so you can use it in personal and commercial projects.
How do I install Stack Tower?
One command: npx shadcn@latest add https://aicanvas.me/r/stack-tower.json, free with a free AI Canvas account. Or connect the AI Canvas MCP server and ask your AI editor to install Stack Tower for you. You can also copy the source straight from the Code tab, no account needed. It works in any React project with Tailwind CSS.
What is Stack Tower built with?
Stack Tower is built with React and TypeScript, using Motion and Tailwind CSS. It ships with both light and dark styling.
Where would I use Stack Tower?
Common uses include Hero section, Type specimen, and Portfolio. Like every AI Canvas component, it is self-contained and drops into any React project.
Can I remix Stack Tower with AI?
Yes. Stack Tower ships with one comprehensive AI prompt written against the real source code. Open "Remix with AI" on this page to read and copy it into Claude, Cursor, ChatGPT, or any AI tool. Prompts are for remixing your own variation; for the exact component, install it with the one-command CLI.
One comprehensive prompt, written against the real source code. Works in Claude, Cursor, ChatGPT, or any AI tool you use.
This prompt is for remixing. Use it to build your own variation of Stack Tower. Results depend on the model you use, and no prompt in the world is 100% exact.
Want the exact component?
One command installs it, pixel-perfect. Copy, paste into your project, done.
npx shadcn@latest add @aicanvas/stack-tower
AI prompt for Stack Tower
Before writing any code, verify the project has Tailwind CSS v4, TypeScript, and React set up. If missing, use the shadcn CLI to scaffold them.
Build a single-file React component called `StackTower` with `'use client'` at the top. Install dependency: `framer-motion`.
---
## Concept
A vertical column of 12 stacked text rows that reads as a 3D cylinder rotating around its vertical axis — built entirely from 2D CSS transforms (translateX + skewX + scale). The rotation "travels" down the stack because each row's phase is offset from the one above. Hover any row to highlight it in an orange accent; motion rhythm is untouched.
---
## Layout & theme
Single full-viewport container: `flex min-h-screen w-full items-center justify-center overflow-hidden`. Background inline-styled per theme. Inside it, a centred flex column capped at `width: 'min(92vw, 620px)'` holds the 12 rows.
**Dual theme — inverted palette, raw hex only:**
- Dark: bg `#0A0A0A`, fg `#EFEEE6`, dim (back-face) `#3A3936`
- Light: bg `#EFEEE6`, fg `#0A0A0A`, dim `#C7C3B8`
- Hover accent (both themes): `#F16D14` (warm orange)
Detect theme via `element.closest('[data-card-theme]')` first (for isolated card previews), falling back to `document.documentElement.classList.contains('dark')`. Walk up the ancestor chain with a MutationObserver watching `class` and `data-card-theme` so the component reacts when any ancestor toggles.
---
## Content
Top-of-file constants:
```ts
const WORDS = ['STACK', 'TOWER'] as const
const ROW_COUNT = 12
```
Row `i` renders `WORDS[i % 2]`. Each row uses Manrope-ish heavy display sans (`var(--font-sans)` with system fallbacks), `fontWeight: 900`, `fontSize: 'clamp(1.75rem, 9vw, 4.5rem)'`, `lineHeight: 0.92`, `letterSpacing: '-0.03em'`, `whiteSpace: 'nowrap'`, centred.
---
## The rotation math
Each row owns a Framer Motion `MotionValue<number>` called `phase`. All phases advance at **exactly the same rate** — hover does NOT modulate speed. Per-row "where am I on the cylinder" is derived from a fixed offset:
```ts
const SECONDS_PER_CYCLE = 5
const AMPLITUDE_PX = 22
const rowOffset = rowIndex * 0.35 // radians — rotation travels down the stack
```
Per frame, each row reads its phase and computes:
```ts
const local = phase * Math.PI * 2 + rowOffset
const scaleX = 0.55 + 0.45 * Math.cos(local) // 0.10 → 1.00 → 0.10
const shiftX = Math.sin(local) * AMPLITUDE_PX // horizontal swing
const skewX = Math.sin(local) * 6 // degrees — barrel
const boost = 1 + hoverAccent * 0.10 // hover scale pop
transform = `translateX(${shiftX}px) skewX(${skewX}deg) scale(${Math.max(0.08, scaleX) * boost}, ${boost})`
```
Colour is a `cos`-driven mix between `dim` and `fg` so "front-facing" rows look bright and "back-facing" rows look dim. Hover then blends the whole thing toward `#F16D14`:
```ts
const tt = (Math.cos(local) + 1) / 2 // 0..1, 1 = full front
const base = mix(dim, fg, tt) // sRGB hex lerp
const col = hoverAccent < 0.002 ? base : mix(base, accent, hoverAccent)
```
Write a tiny `mix(a, b, t)` helper that parses `#RRGGBB` with `parseInt(hex.slice(1), 16)`, lerps each channel, and returns a padded hex string.
---
## Central animation loop
One `useAnimationFrame` in the parent — NOT one per row. Children subscribe to per-row MotionValues via `useTransform`; the parent owns phase advancement and hover easing.
```ts
const HOVER_EASE_RATE = 10 // 1/s
useAnimationFrame((t) => {
const dtSec = (t - prevTs) / 1000
const phaseDt = dtSec / SECONDS_PER_CYCLE
const alpha = 1 - Math.exp(-HOVER_EASE_RATE * dtSec) // frame-rate independent
const hov = hoveredIndexRef.current
for (let i = 0; i < ROW_COUNT; i++) {
phases[i].set(phases[i].get() + phaseDt) // rate is always 1
const target = i === hov ? 1 : 0
hoverEased[i] += (target - hoverEased[i]) * alpha
hovers[i].set(hoverEased[i])
}
})
```
Create the per-row MotionValues with `useMemo(() => Array.from({length: ROW_COUNT}, () => motionValue(0)), [])` — the plain `motionValue` factory, not the hook.
---
## Hover tracking — critical for smoothness
Store the hovered row index in a **ref**, not state:
```ts
const hoveredIndexRef = useRef<number | null>(null)
const handleEnter = (i) => { hoveredIndexRef.current = i }
const handleLeave = (i) => { if (hoveredIndexRef.current === i) hoveredIndexRef.current = null }
```
This is load-bearing: using `useState` here re-renders the whole stack on every hover change, which visibly hiccups the rotation. Refs avoid that.
Each row wraps its `<motion.div>` in an outer pointer-target `<div>` with `width: 100%`, `display: flex`, `justifyContent: 'center'`, `cursor: 'pointer'`, `touchAction: 'none'`, and handlers `onPointerEnter/Leave/Down/Up/Cancel`. The outer div stays a stable rectangle even when the inner `<motion.div>` scales down to 0.08× — so hover targeting never fails.
---
## Top + bottom fades
Two absolutely-positioned fade `<div>`s at `top: 0` and `bottom: 0`, each 22% tall, with a linear gradient from bg-solid to transparent, give the "infinite cylinder" feel. `pointerEvents: 'none'` so they don't block hover.
---
## Reduced motion
Respect `(prefers-reduced-motion: reduce)`: when set, freeze every row's phase at `0.2 + rowIndex * 0.03` so the stagger is visible but nothing moves.
---
## Cleanup
Every `MutationObserver` disconnected on unmount. No other listeners or RAFs to clean — Framer Motion handles `useAnimationFrame` cleanup automatically.
The whole component is one file with a default export.