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.
Panels on the endless track, in order; each needs an image URL, alt text and a caption, plus an optional focus from 0 to 1 marking where in the image the subject sits vertically.
eyebrow
string
'Portfolio'
Small uppercase kicker above the title, also used as the carousel label for screen readers.
title
string
'Things we made on purpose'
Heading shown inside the frame's top band.
subtitle
string
'Made with intent and a stubborn sense of proportion. Classical bones, modern finish, nothing decorative.'
Supporting line under the title, hidden on stages narrower than 480px.
hint
string
''
Optional input hint under the readout row; empty by default, and hidden on stages narrower than 480px when set.
Outlined call to action. Sits beside ctaPrimary when both are set and is dropped on stages under 400px in that case; when it is the only action it always renders. Pass null for none.
autoDrift
boolean
true
Whether the track drifts on its own while idle.
driftSpeed
number
26
Idle drift speed in pixels per second.
panelAspect
number
2
Width divided by height of the panel crop visible in the middle of the frame; raise it for a wider, shorter centre band.
edgeSlotRatio
number
0.78
Visible slot height at each stage edge, as a fraction of stage height. Lower it for a shallower curve, raise it toward 1 for a deeper one.
flareRatio
number
0.28
Horizontal run of the flare per side, as a fraction of stage width. This is the strongest lever on the frame's character: low leaves a straight band with rounded corners, high turns the whole frame into one continuous sweep.
flareTension
number
0.72
Character of the flare, as the bezier handle length over the run. Low turns early and bluntly, high hugs the straight line and the edge and puts the turn in the middle. 0.35 blunt, 0.5523 exact quarter ellipse, 0.72 squircle, 0.85 very taut.
edgeRotation
number
24
Maximum edge foreshortening in degrees; 18 is barely there, 32 is the ceiling before panels read as folded paper.
onIndexChange
(index: number, item: object) => void
-
Called with the index and item whenever a new panel becomes the one nearest the slot centre.
className
string
-
Extra classes merged onto the outermost root element.
Frequently asked questions
Is Curved Frame Carousel free to use?
Curved Frame Carousel is a Premium AI Canvas component. Premium unlocks the full source code and one-command installs for every Premium component and template.
How do I install Curved Frame Carousel?
Premium unlocks one-command installs: run the shadcn CLI with your AI Canvas token, or connect the AI Canvas MCP server and ask your AI editor to install it for you. You can also recreate your own take on it with the included AI prompts.
What is Curved Frame Carousel built with?
Curved Frame Carousel is built with React and TypeScript, using Hero, Gallery, Interactive, and Motion. It ships with both light and dark styling.
Can I remix Curved Frame Carousel with AI?
Yes. Curved Frame Carousel 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 Curved Frame Carousel. 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.
Build a "Curved Frame Carousel": a full-bleed landing page hero in which one endless horizontal track of identical full-height photo panels slides underneath ONE static, page-coloured frame drawn as a single inline SVG path. Ship it as ONE self-contained, configurable React client component ('use client') in TypeScript, styled with Tailwind arbitrary values (raw hex only, no design tokens) and driven by framer-motion. No other dependency: no image library, no 3D library, no carousel library.
THE EFFECT, precisely
The frame is the hero. It is the full stage rectangle minus a horizontal slot, filled flat with the exact page colour. The slot is short and perfectly straight through the middle of the stage, then flares open toward both stage edges on a quarter-ellipse curve. The frame never moves and never animates; the panels move under it. So a panel is revealed short while it is in the centre and progressively taller as it slides into either edge, and that reveal IS the component.
One supporting layer restates the same idea in three dimensions: panels approaching either edge hinge away on their INNER vertical edge under a CSS perspective, so the outer edge recedes, the top edge slopes down and the bottom edge slopes up. It is a supporting layer, never a second hero.
The headline block sits inside the frame's top band and the caption readout sits inside its bottom band, so the copy is part of the frame rather than something stacked above it.
TECH FRAME AND FILE SHAPE
Single file, default export `CurvedFrameCarousel`, every prop optional so a no-props render is the tuned composition. Also export the types `CurvedFrameItem` ({ src, alt, caption, focus? }) and `CurvedFrameAction` ({ label, href?, onClick? }).
Define `const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect` and use it for every measurement effect, so server rendering never warns.
From framer-motion use exactly `motion`, `useMotionValue`, `useTransform`, `wrap`, and the `MotionValue` / `PanInfo` types. There must be NO `animate()` call anywhere in the file; see MOTION MODEL for why.
MODULE CONSTANTS (use these names and values)
`COAST_TAU = 0.55` seconds for a released throw to settle back into the drift speed. `DRIFT_START = 150` ms after mount. `DRIFT_RESUME = 250` ms after the last interaction clears. `RAMP_IN = 0.3` and `RAMP_OUT = 0.26` seconds. `BLEED = 0` and it must stay zero (see WHAT IS DELIBERATELY ABSENT). `FOCUS_ZOOM = 1.4`. `DEFAULT_FOCUS = 0.34`, the upper third, where a subject's head usually sits. `FLARE_MIN_ASPECT = 2.5`. `OVERSHOOT = 2` px. `LUT_PER_PITCH = 32`. Keep 0.5523 in a comment as the exact quarter-ellipse handle ratio, the reference value the `flareTension` prop is measured against.
Helpers: `clamp(v, lo, hi)`, `clamp01(t)`, `smooth(t) = t*t*(3-2*t)` (smoothstep), `r2(v) = Math.round(v*100)/100` for rounding SVG path coordinates.
DOM SKELETON AND STACKING (the bug everyone hits)
Root: `flex min-h-screen w-full flex-col items-center justify-center overflow-x-hidden py-[32px] md:py-[48px] xl:py-[64px]`, background `bg-[#EDEAE3] dark:bg-[#111110]`, inline `fontFamily: 'Manrope, ui-sans-serif, system-ui, sans-serif'`, all colour custom properties declared here as Tailwind arbitrary properties with `dark:` variants, plus the caller's `className` merged last.
Root children, in order: header wrapper, stage, readout wrapper, a visually hidden `<ol>` of captions, a visually hidden `aria-live="polite"` region.
The STAGE must create its own stacking context: `position: relative`, `isolation: 'isolate'`, `zIndex: 0`, `overflow-hidden`. Otherwise its internal layers compete in the root stacking context and the frame paints over the header, which then vanishes at some widths. Inside the stage: the 3D wrapper at z-0, the rest scrim at z-10, the SVG frame at z-20, the focus ring at z-30. The header and the readout are SIBLINGS of the stage at `z-[1]`, so they always paint above the entire stage, including the frame, at every width with no breakpoint switch.
The bands are formed by negative margins, never by absolute positioning: the stage carries `marginTop: -insetTop` and the readout carries `marginTop: -insetBottom`. Both text wrappers are `pointer-events-none` so the bands stay draggable; only the CTA buttons re-enable pointer events.
MEASUREMENT, never media queries
Every geometric number comes from the MEASURED stage width, because this block renders both in a small card preview and in a near-fullscreen expanded view on the same page. A viewport media query would be wrong in one of the two.
- `width` state starts at 1200 and is replaced pre-paint by a `ResizeObserver` on the stage: `Math.round(entry.contentRect.width)`, ignoring anything below 1px, and setting a `measured` ref true.
- `insetTop` starts at 148 and `insetBottom` at 96, and both are then MEASURED with their own ResizeObservers on the header and readout wrappers, using `offsetHeight` (not `contentRect`) so their padding counts. Never a fixed ratio of the stage: the bands are always exactly as tall as their content, so copy can never collide with the panels no matter how much of it there is.
- Park at rest exactly ONCE, on the first real measure: guard with a `parked` ref plus the `measured` ref, then `x.set(g.restX)`. `restX` depends only on width and pitch, so the later band measurements never move it and a resize mid-drag never yanks the track back.
- Every panel transform reads geometry from a ref (`gRef.current = g` assigned during render), so a geometry change while `x` is still would leave every panel painted against the previous stage. Fix it with one effect keyed on `g` that nudges the motion value: `x.set(x.get() + 0.001)`. It must be keyed on the memoised geometry, not fired inside the ResizeObserver, because the observer runs BEFORE the width state commits and would recompute against the geometry it is trying to replace.
GEOMETRY (one pure function of the measured width, memoised)
`panelW = round(clamp(w * 0.42, 240, 720))`, `pitch = panelW` (panels butt together, no gap and no divider), `boxW = panelW + 2 * BLEED`.
`flare = clamp(w * flareRatio, w * 0.1, min(w * 0.32, 720))`. The floor is a FRACTION of the stage, never an absolute pixel floor: an absolute floor is what breaks 320px.
`flatRun = w - 2 * flare`, `headerMaxW = max(min(720, flatRun - 24), min(w - 32, 200))`, so the copy sits on the straight run and never over a flared corner.
The centre crop is DESIGNED, not whatever falls out of two colliding clamps: `slotH = round(panelW / clamp(panelAspect, 0.4, 6))` and `stageH = insetTop + slotH + insetBottom`. Clamping `panelAspect` is not cosmetic: it is buyer-supplied and it is a DIVISOR, so 0 would make `slotH` Infinity and NaN the stage height, the frame path and every transform, and a negative value would give a negative stage height.
Asymmetric edge bands: `edgeBand = max(0, stageH * (1 - edgeSlotRatio))`, `sum = max(1, insetTop + insetBottom)`, then
`edgeInsetTop = max(min(insetTop, edgeBand * insetTop / sum), insetTop - maxTravel)`
`edgeInsetBottom = max(min(insetBottom, edgeBand * insetBottom / sum), insetBottom - maxTravel)`
with `maxTravel = flare / FLARE_MIN_ASPECT`. Whatever band is left at the stage edge is split in the same proportion as the two content bands, so the flare stays balanced.
FLARE_MIN_ASPECT is not decoration and it is the single easiest thing to leave out. The flare's horizontal run scales with WIDTH, while its vertical travel scales with `insetTop`, which GROWS on a narrow stage as the title and subtitle wrap onto more lines. The two move in opposite directions, so a curve that is a lazy 1:3.3 sweep at 1900px turns into a 1:0.7 cliff at 550px with the ratios unchanged. Capping the travel at run / 2.5 keeps the character fluid as the stage shrinks, and it never fires on a wide stage, where the natural travel is already well inside it.
3D ramp anchors: `flatHalf = max(min(w/2 - flare + panelW * 0.3, w/2 - panelW * 0.15), panelW * 0.55)` and `band = max(w/2 - flatHalf, panelW * 0.22)`. The flat band is deliberately WIDER than the frame's straight slot run, so a panel is fully flat well before it reaches the straight part of the slot, and it never starts closer in than half a panel from the centre, which keeps the centre-straddling panel unrotated.
`persp = w >= 1024 ? 1800 : 1400`, `maxRot = edgeRotation`.
Ramp function used by every rotation: `rampOf(c) = smooth(clamp01((abs(c) - flatHalf) / band))`, where `c` is a panel's signed centre offset from the stage centre. It is 0 through the whole flat band and smoothsteps to 1 by the stage edge.
THE FRAME PATH (the hero, one path, one fill)
One `<path>` inside one inline `<svg>`: an outer rectangle plus one slot subpath, `fillRule="evenodd"` so the slot is a hole, `fill="var(--page)"`, `shapeRendering="geometricPrecision"`, no stroke, no gradient, no blur, no shadow.
Each flare is a QUARTER ELLIPSE, not a symmetric S. The handle at the inner end is HORIZONTAL, so the flare meets the straight centre line on a zero tangent with no kink; the handle at the stage edge is VERTICAL, so the boundary is still climbing when it exits the stage. Horizontal handles at both ends flatten the curve into a shoulder that reads as a cheap wave, and that is the most likely way to get this wrong.
Given the stage width `w`, height `h`, the two inner insets, the two edge insets, the flare run and the tension:
`F = min(flare, w/2)`, `t = clamp(tension, 0.2, 0.95)`
`kh = t * F` (horizontal handle, at the inner end), `kvT = t * abs(insetTop - edgeTop)` and `kvB = t * abs(insetBottom - edgeBottom)` (vertical handles, at the stage edges)
`IT = insetTop`, `ET = edgeTop`, `IB = h - insetBottom`, `EB = h - edgeBottom`, `ETv = edgeTop + kvT`, `EBv = h - edgeBottom - kvB`
path, with `O = OVERSHOOT` and every number rounded to 2 decimals:
`M -O -O H (w+O) V (h+O) H -O Z`
`M 0 ET C 0 ETv, (F-kh) IT, F IT`
`H (w-F)`
`C (w-F+kh) IT, w ETv, w ET`
`V EB`
`C w EBv, (w-F+kh) IB, (w-F) IB`
`H F`
`C (F-kh) IB, 0 EBv, 0 EB Z`
THE OVERSHOOT IS LOAD BEARING. The outer rectangle extends OVERSHOOT px past the stage on all four sides, and the `<svg>` must be sized `w + OVERSHOOT*2` by `stageH + OVERSHOOT*2` with a MATCHING offset viewBox `"-2 -2 (w+4) (h+4)"` and inline `left: -OVERSHOOT, top: -OVERSHOOT`. An svg viewport clips by default, so with `viewBox="0 0 w h"` every negative coordinate is discarded, the fill ends exactly on the stage boundary, and it antialiases there into a visible hairline of the image along the top and both sides. With the offset viewBox the stage's own `overflow-hidden` does the clipping instead, as a hard box clip with no soft edge.
The svg is `aria-hidden`, `pointer-events-none`, `absolute z-20`. Recompute the path only when geometry changes. Never animate it.
THE ENDLESS TRACK
ONE motion value `x` drives everything through `useTransform` only: no per-frame React state, no re-render on pointer move.
Node pool: `need = max(itemCount, ceil((w + 4*pitch) / pitch))`, then `nodeCount = ceil(need / itemCount) * itemCount`, `total = pitch * nodeCount`. The pool comes from the MEASURED stage width, never from the item count. A pool of exactly `items.length` breaks the moment the stage is wider than `items.length * panelW`: the track runs out of panels and a hole opens at the incoming edge. Rounding up to a whole multiple of the item count matters too, because the modulo mapping would otherwise show the same photo twice side by side where the pool wraps.
Node `i` renders `items[i % items.length]`. Its signed centre offset is `cx = wrap(-total/2, total/2, i*pitch + x)` via `useTransform`, which is the whole loop: no clones, no seam, in either direction.
Per panel, all derived from `cx`:
- `pivot(c) = sign(c) * mapX(abs(c) - pitch/2)`, the screen position of the panel's hinge, which is its inner vertical edge.
- transform: `translate3d(<pivot(c) + sign(c) * (boxW/2 - BLEED)>px,0,0) rotateY(<sign(c) * maxRot * rampOf(c)>deg)`, the translation written with `toFixed(2)` and the rotation with `toFixed(3)`. Positions are SUB-PIXEL on purpose: never round the translation to whole pixels, that quantises the drift into one visible step per pixel, which at drift speed reads as stutter rather than movement. Always emit `rotateY` even at exactly 0, because omitting it inside the flat band puts those panels on a different compositor path from the rotating ones and a panel crossing that boundary flips rasterisation mid-slide.
- transformOrigin: `(boxW - BLEED)px 50%` when `c < 0`, `BLEED px 50%` otherwise, so the hinge is always the vertical edge NEAREST the centre and neighbours stay joined.
- zIndex: `10000 - Math.round(abs(c))`, per pixel. Never bucket it (`100 - round(abs(c)/10)` and friends), because two neighbours inside one bucket TIE, paint order falls back to DOM order, which is pool index rather than distance, and every time a tie forms and breaks the overlap flips and a hairline blinks.
- visibility: `hidden` once `abs(pivot(c)) > w/2 + BLEED`, so the test is exact and accounts for the foreshortening. `visibility: hidden` also drops the node from the accessibility tree.
Panel box: `absolute left-1/2 top-0 overflow-hidden`, `width: boxW`, `height: stageH` (full stage height, always), `marginLeft: -boxW/2`, `willChange: 'transform'`, `backfaceVisibility: 'hidden'`. Horizontal translation only: no vertical offset, no scale, no translateZ, no filter, no shadow, no border, no radius, no background.
The 3D context: the stage holds one wrapper `absolute inset-0 z-0` with `perspective: <persp>px` and `perspectiveOrigin: '50% 50%'`, and inside it one wrapper with `transformStyle: 'preserve-3d'` holding the panels. The frame, the scrim and the focus ring are siblings of that wrapper, outside the 3D context, permanently flat.
NO GAP UNDER ROTATION (the hard part, build the LUT)
A hinged panel is narrower on screen than its flat width, both by cos(rot) and by the perspective divide, since its outer edge now sits further from the viewer. Placing panels on the raw pitch opens a visible wedge between them. So place the hinges by an exact recurrence, precomputed once per geometry into a lookup table:
`lutStep = pitch / LUT_PER_PITCH` (32 samples per pitch, so the step divides the pitch exactly and neighbouring queries share a fractional phase)
`nSamp = ceil((w/2 + 2.5*pitch) / lutStep) + 1`
for `i <= LUT_PER_PITCH`: `lut[i] = i * lutStep` (seed: the centre-straddling panel is flat, so P(u) = u there)
otherwise, with `u = i*lutStep` and `a = maxRotInRadians * smooth(clamp01((abs(u - pitch/2) - flatHalf) / band))`:
`lut[i] = (lut[i - LUT_PER_PITCH] + pitch * cos(a)) * (persp / (persp + pitch * sin(a)))`
Here `u` is a panel's nominal inner-edge offset from the stage centre and `a` is the rotation of its INNER NEIGHBOUR, the panel whose centre sits at `u - pitch/2`. Each panel's hinge therefore lands exactly on the projected outer edge of the one before it, at every width and every rotation.
`mapX(u)` is an odd function over that table: take the sign, index with `f = abs(u)/lutStep` and `i = floor(f)`, linearly interpolate between `lut[i]` and `lut[i+1]`, and past the last sample extrapolate linearly on the slope of the final pair. Note that this interpolation is linear while the recurrence it samples is not (sin, cos, a perspective divide), so the tiling is sub-pixel approximate rather than exact, which is the correct trade at 32 samples per pitch.
REST OFFSET
A panel divider sitting exactly on a stage edge kills the bleed read, so pick a rest offset that clears them. Walk outward from 0 in 49 steps, `off = (i % 2 ? -1 : 1) * ceil(i/2) * (pitch/48)` for i in 0 to 48, and for each candidate measure the clearance: for every nominal divider `j = off + pitch/2 + k*pitch` within `w/2 + 2*pitch`, take `min(abs(mapX(j) - w/2), abs(mapX(j) + w/2))`. Measure it in SCREEN space through `mapX` so the foreshortening is accounted for. Return the first offset whose clearance reaches 24px, else the best found, rounded. Walking outward from 0 keeps a panel near the slot centre. It is a 49-sample scan that runs once per resize; a closed form is not worth it.
MOTION MODEL: ONE VELOCITY, NO ANIMATIONS
There is exactly one velocity ref, in px/s, negative for the leftward drift, plus a `gain` ref (0 to 1) and a `resumeAt` timestamp ref. One `requestAnimationFrame` loop owns `x`, and nothing else ever animates it. Do NOT add a framer `animate()` for the release coast or for arrow keys: an inertia or spring tween OWNS the motion value while it runs, so the drift underneath contributes nothing and the track visibly stops for a second or two before starting again. One velocity that decays toward the drift speed instead of toward zero means the track is never not moving.
The loop (skipped entirely when `reduced` or `autoDrift` is false):
`dt = min((now - last)/1000, 0.05)`
`awake = !document.hidden && now >= resumeAt && !dragging`
`ramp = awake && gain < 1 ? RAMP_IN : RAMP_OUT`
`gain += ((awake ? 1 : 0) - gain) * min(dt/ramp, 1)`
`target = -driftSpeed * gain`
`vel += (target - vel) * min(dt/COAST_TAU, 1)`
if not dragging and `abs(vel) > 0.01`: `x.set(x.get() + vel * dt)`
Set `resumeAt = performance.now() + DRIFT_START` when the loop mounts. Constant speed: never modulate it with a sine wobble or a pitch-synced dwell, because on a track of straight edges sliding past a fixed frame any speed modulation reads as hesitation.
Pause and resume: `pauseDrift()` sets `resumeAt = Infinity`; `maybeResume()` returns early if the stage is focused, else sets `resumeAt = now + DRIFT_RESUME`. Drift is paused by INTERACTION (pointer down, horizontal wheel, keys) and by focus, NEVER by hover. Hover-pausing looks considerate on a small card and is a trap everywhere else: the stage fills the viewport when the block is used as a real hero, so the cursor is over it almost all the time, and an Infinity pause only ends on a pointerleave that never arrives.
Drag: `onPointerDown` calls `pauseDrift()` and must NOT zero `gain`, otherwise every press throws away the ramp-up and releasing starts the fade-in from nothing. `onPanStart` records `startX = x.get()`. `onPan` sets `x = startX + info.offset.x`, 1:1, no rubber band, because the track is endless, and flags `dragged` once the offset passes 6px. `onPanEnd` hands the throw straight to the shared velocity: `vel = reduced ? 0 : clamp(info.velocity.x, -2400, 2400)` and `resumeAt = performance.now()` with no delay, so the drift absorbs it over COAST_TAU. Add `onPointerUp` and `onPointerCancel` handlers that call `maybeResume()` unless a drag is in progress: a press that never crosses framer's pan threshold produces no `onPanEnd`, so without them a plain click on the stage parks the drift at Infinity for good. Suppress the synthetic click after a real drag with `onClickCapture` (preventDefault, stopPropagation, reset the flag); a tap under 6px is a no-op.
Wheel: a native listener on the stage with `{ passive: false }`, removed on cleanup. HORIZONTAL INTENT ONLY: if `abs(deltaX) <= abs(deltaY)`, return immediately and let the page scroll. Otherwise preventDefault, pause, `x.set(x.get() - e.deltaX)`, then re-arm. Mapping vertical delta onto the track traps page scroll for anyone whose cursor is over the block, which on a full-viewport hero is most of the viewport most of the time. Also set `touchAction: 'pan-y'` on the stage so vertical page scroll survives on touch.
Keys on the stage: ArrowRight and ArrowLeft jump exactly one pitch with NO animation (`x.set(x.get() -/+ pitch)`), Home jumps to `restX`. Same reason as the release: a spring here would own `x` for its duration and stop the loop dead. The drift is never interrupted, the track simply continues from the new offset. Each key jump announces through the live region.
Index readout: subscribe to `x` once with `x.on('change', ...)`, compute `(((round(-v / pitch) % N) + N) % N)`, and write state ONLY when that value changes, never per frame. Fire `onIndexChange?.(next, items[next])` on the same edge. Return the unsubscribe from the effect.
IMAGES: FOCAL ALIGNMENT AND THE LOAD GATE
A landscape source in a tall panel is scaled by HEIGHT under `object-fit: cover`, so it fills the panel vertically with no overflow at all and `object-position`'s Y axis does literally nothing. Meanwhile the slot only reveals the band between the two frame insets, so the top of every image, where faces usually are, sits hidden behind the top band. The fix is to create real vertical overflow and then slide it:
`height = round(stageH * FOCUS_ZOOM)`
`slotMid = insetTop + (stageH - insetTop - insetBottom) / 2`
`top = round(clamp(slotMid - clamp01(item.focus ?? DEFAULT_FOCUS) * height, stageH - height, 0))`
The image is `absolute left-0 w-full object-cover` with that height and top, `objectPosition: '50% 50%'`. The clamp guarantees the image can never uncover the panel at either end.
Reveal gate: `opacity: loaded ? 1 : 0` with `transition: 'opacity 400ms ease-out'`. `loaded` must settle from the element itself on mount, `setLoaded(Boolean(el?.complete && el.naturalWidth > 0))` in a layout effect KEYED ON `item.src`, in addition to `onLoad`. These img tags are server rendered, so the browser can finish loading them from the HTML before React hydrates and attaches `onLoad`; that load event is gone for good and an onLoad-only gate leaves a fully loaded image stuck at opacity 0 forever. Keying on src matters because the pool reuses nodes, and a node that already reported loaded would stay revealed while a DIFFERENT image was still downloading into it.
`loading="eager"` on every node, never lazy: off-stage nodes are `visibility: hidden`, so a lazy image is not fetched until the drift carries it in at 26px/s, and the incoming side renders as an empty panel for tens of seconds. There are only `items.length` distinct URLs however many nodes exist, so the browser fetches each once and serves every repeat from cache. Add `fetchPriority={index < 3 ? 'high' : 'auto'}`, `decoding="async"`, `draggable={false}`.
PROP API (typed, all optional, defaults in brackets)
- `items` [a built-in array of 5] the panels on the endless track, in order. Each is `{ src, alt, caption, focus? }`, where `focus` is 0 at the image's top edge and 1 at its bottom and defaults to 0.34. Guard the empty array explicitly (`itemsProp.length > 0 ? itemsProp : DEFAULT_ITEMS`): it is reachable, the prop default does not catch it, and the item count is a divisor, a modulus and the pool rounding factor, so zero gives NaN geometry and an unbounded pool.
- `eyebrow` ['Portfolio'] small uppercase kicker, also used as the carousel's accessible label.
- `title` ['Things we made on purpose'].
- `subtitle` ['Made with intent and a stubborn sense of proportion. Classical bones, modern finish, nothing decorative.'] hidden on stages narrower than 480px.
- `hint` [''] optional uppercase line under the readout; empty by default, and hidden under 480px when set.
- `ctaPrimary` [null] filled action, `{ label, href?, onClick? }`.
- `ctaSecondary` [{ label: 'Start a project' }] outlined action; pass null for none.
- `autoDrift` [true].
- `driftSpeed` [26] idle drift in px per second.
- `panelAspect` [2] width over height of the centre crop; raise it for a wider, shorter centre band.
- `edgeSlotRatio` [0.78] visible slot height at each stage edge as a fraction of stage height; lower for a shallower curve, toward 1 for a deeper one.
- `flareRatio` [0.28] horizontal run of the flare per side as a fraction of stage width. The strongest lever on the frame's character: low leaves a straight band with rounded corners, high turns the whole frame into one continuous sweep.
- `flareTension` [0.72] bezier handle length over the run. 0.35 blunt and early-turning, 0.5523 the exact quarter ellipse, 0.72 squircle, 0.85 very taut. Clamped to 0.2 to 0.95.
- `edgeRotation` [24] maximum edge foreshortening in degrees; 18 is barely there, 32 is the ceiling before panels read as folded paper.
- `onIndexChange` called with (index, item) whenever a new panel becomes the one nearest the slot centre.
- `className` merged onto the outermost root element.
Default items: five landscape images from an image CDN with format negotiation in the query (the shipped defaults use `?tr=w-1200,q-80,f-auto`, which turns a multi-megabyte PNG into a few hundred KB and covers a retina panel), with focus values 0.42, 0.28, 0.34, 0.3, 0.28 and captions "A doorway that opens onto nothing but weather", "The night the moon came in close", "The observatory keeps an orbit of its own", "He stops at the bottom of the stair", "The same climb, drawn from memory". Every item needs real alt text describing the picture.
COPY, TYPE AND SPACING (px only, every size chosen from the measured w)
Header wrapper: `marginTop` 32 under 768, 40 under 1280, else 48; `paddingTop` 20, `paddingBottom` 48; `maxWidth: headerMaxW`; centred text.
Eyebrow 10px / 600 uppercase, leading 1, tracking 0.18em, colour `--fg2`. Title 24px under 480, 28px under 1280, else 32px, weight 800, leading 1.15, tracking -0.02em, colour `--fg`, `marginTop` 10. Subtitle (only at 480 and up) 14px under 768 else 16px, weight 400, leading 1.5, `maxWidth` 520, centred, `marginTop` 12, colour `--fg2`.
CTA row (only when at least one action is set): flex wrap, centred, `marginTop` 16 under 480 else 20, gap 12, switching to a full-width column with gap 8 under 400px. In that stacked state the secondary action is dropped when a primary is also set, and always renders when it is the only action.
Readout wrapper: `paddingTop` 16, `paddingBottom` 20, same maxWidth, `marginTop: -insetBottom`. Inside, one row that becomes a column under 640px (gap 12 becomes 6, baseline alignment becomes centre): the counter at 12px / 600, tabular-nums, tracking 0.10em, colour `--fg3`, formatted "01 / 05" with the current number in the accent and both numbers zero-padded to 2; then the caption at 14px (16px from 1200), weight 700, leading 1.35, tracking -0.005em, `textWrap: 'balance'`, colour `--fg`.
THE CAPTION BOX IS RESERVED AT EXACTLY TWO LINES: `display: '-webkit-box'`, `WebkitBoxOrient: 'vertical'`, `WebkitLineClamp: 2`, `overflow: 'hidden'`, and an explicit `height: round(fontSize * 1.35 * 2)`. The clamp alone caps the maximum but sets no minimum, so a short caption renders one line tall and a long one two; since `insetBottom` is measured from this row and `stageH = insetTop + slotH + insetBottom`, the whole stage would grow and shrink by a line as the caption changed with each panel. Reserving the taller state holds the layout still.
Hint, when set and at 480 and up: 10px / 500 uppercase, leading 1, tracking 0.14em, colour `--fg3`, `marginTop` 12.
COLOUR (raw hex via CSS custom properties on the root, with dark: variants, both themes equally intentional)
Light: `--page` #EDEAE3, `--fg` #1C1916, `--fg2` #6B655C, `--fg3` #9A948A, `--accent` and `--focus` #B04E2B, `--scrim` rgba(237,234,227,0.10), `--plate` `0 1px 0 rgba(28,25,22,0.06)`, `--cta` #B04E2B with `--cta-fg` #F6F3EC, `--cta-hover` #9E4425, `--cta-active` #8E3C20, `--cta-shadow` `0 8px 20px -8px rgba(176,78,43,0.45)`, `--ring` rgba(28,25,22,0.22), `--ring-hover` rgba(28,25,22,0.40), `--ghost` rgba(28,25,22,0.04).
Dark: `--page` #111110, `--fg` #EDEBE6, `--fg2` #8B877E, `--fg3` #5C5952, `--accent` and `--focus` #C8613C, `--scrim` rgba(17,17,16,0.14), `--plate` none, `--cta` #C8613C with `--cta-fg` #111110, `--cta-hover` #D46E48, `--cta-active` #B85634, `--cta-shadow` `0 8px 20px -8px rgba(0,0,0,0.5)`, `--ring` rgba(237,235,230,0.28), `--ring-hover` rgba(237,235,230,0.48), `--ghost` rgba(237,235,230,0.06).
The frame fill is always exactly `var(--page)`, flat, no gradient, and the stage's own background is the same page colour with `boxShadow: var(--plate)`. One warm neutral family plus one terracotta accent; the photos carry every other colour.
Rest scrim: one `pointer-events-none absolute inset-0 z-10` div with `backgroundColor: var(--scrim)` and `transition-colors duration-[260ms] ease-out`, lifted by `hover:[--scrim:transparent]` on the stage. It tints the panels at rest and clears on hover; it is a mood layer, not a vignette.
CALL TO ACTION
One small internal component for both actions. Base: `pointer-events-auto inline-flex h-[44px] min-w-[44px] items-center justify-center rounded-[999px] px-[20px] text-[14px] font-semibold leading-[1] no-underline`, `transition-[background-color,box-shadow,transform] duration-[160ms] ease-out`, `active:scale-[0.97] active:duration-[120ms] motion-reduce:active:scale-100`, `focus-visible` outline 2px solid `var(--focus)` with 2px offset, drawn OUTSIDE the box so the band can never clip it. Primary: `bg-[var(--cta)] text-[var(--cta-fg)] shadow-[var(--cta-shadow)]` with hover and active background swaps. Secondary: transparent with `inset 0 0 0 1px var(--ring)`, hover to `var(--ghost)` and `var(--ring-hover)`. Colour must live in CLASSES, never in an inline style: an inline background outranks the hover and active variants and freezes the button. `href` renders an `<a draggable={false}>` and wins over `onClick`, otherwise a `<button type="button">`. Both stopPropagation on pointerdown so the buttons never swallow the stage drag.
ACCESSIBILITY, REDUCED MOTION, CLEANUP
Stage: `tabIndex={0}`, `role="group"`, `aria-roledescription="carousel"`, `aria-label` of the eyebrow plus the item count, outline suppressed in favour of the z-30 inset ring (`inset 0 0 0 2px var(--focus)`) which lives ABOVE the frame so the mask can never hide it and fades in over 120ms on `group-focus-visible`. Focus pauses the drift and blur resumes it, with the blur handler checking `relatedTarget` so moving focus inside the stage never resumes early. Each panel: `role="group"`, `aria-roledescription="slide"`, `aria-label` of its modulo-mapped position. A visually hidden `<ol>` lists the source captions once, and a visually hidden `aria-live="polite"` region announces "Item N of M. {caption}" on each keyboard step. Frame, scrim and focus ring are `aria-hidden`. Tab order is the CTA buttons then the stage; nothing else is focusable.
`prefers-reduced-motion: reduce` (matchMedia, synced and subscribed): no drift loop at all, and a release sets velocity to 0 so there is no coast. Drag, wheel and key steps still work, since they are direct and instantaneous, and the frame geometry and the panel rotations are unchanged because they are position-derived and static.
On unmount: disconnect all three ResizeObservers, remove the wheel listener, cancel the RAF, remove the matchMedia listener and unsubscribe the motion value listener.
WHAT IS DELIBERATELY ABSENT (do not add any of these back, each one was built and removed)
- No whole-pixel rounding of panel positions. It quantises the drift into visible steps.
- No panel box wider than the panel. BLEED is 0 and it cancels out of the position maths (`boxW/2 - BLEED = (panelW + 2B)/2 - B = panelW/2`), so overhang never moves a joint and never closes a gap; all it does is paint every image past both of its true edges, which is invisible between two pale photos and a hard band of the wrong picture the moment a near-black image sits beside a pale one.
- No background on the panel box. A panel bed IS the dark hairline people chase: the box sits on fractional pixels, so the image is antialiased at its edge and goes partly transparent there, letting the bed show THROUGH it, and the centre panel paints that over both neighbours. Transparent lets the antialiased edge blend into the neighbour's image underneath, which is what it should have been doing all along.
- No bucketed z-index, no edge shade gradient, no hinge highlight strip, no per-panel filter, blur, shadow or radius. The shade reached full strength exactly where a panel met its neighbour, so every seam wore a soft dark band, and the highlight drew a bright line down the same joint. Depth comes only from the perspective foreshortening, which is the honest source.
- No spring or inertia tween anywhere, no coast animation, no wobble on the drift speed, no hover pause, no vertical wheel capture, no divider, gap, border or rounded corner between panels, no fade or vignette at the stage edges.
If a hairline compositor seam ever shows at fractional positions, that is the ceiling of independently transformed layers, and the fix past it is one WebGL quad strip sharing joint vertices, which is a rewrite rather than a nudge. More overlap only ever widens the band.
Fully responsive from a 320px card to a full-width hero, driven entirely by the measured stage width. Start the file with `// npm install framer-motion`. Default export the component as `CurvedFrameCarousel`.