Inertia carousel
A carousel you throw: drag with momentum, snap to items, native scroll on touch.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/inertia-carousel.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/inertia-carousel
Eight renders. Throw it, use the arrows, or focus it and press the arrow keys.
Release notes
Narrow text cards with links. Tabbing into one scrolls it into view natively.
Usage
import { InertiaCarousel, InertiaCarouselItem } from "@/components/ui/inertia-carousel"
<InertiaCarousel aria-label="Recent work">
<InertiaCarouselItem>
<img src="/work/one.jpg" alt="The new dashboard" />
</InertiaCarouselItem>
</InertiaCarousel>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | Names the scrolling region on InertiaCarousel. Required. | |
| className | string | On InertiaCarouselItem, set the width. The default is 78vw, then 36rem from md up. |
Source
inertia-carousel/inertia-carousel.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { Draggable } from "gsap/Draggable";
import { InertiaPlugin } from "gsap/InertiaPlugin";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, Draggable, InertiaPlugin);
const LIVE = "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)";
const REDUCED = "(prefers-reduced-motion: reduce)";
type InertiaCarouselProps = Omit<React.ComponentProps<"div">, "aria-label"> & {
/** Names the scrolling region for screen readers. Required. */
"aria-label": string;
};
/**
* A native horizontal scroller, which is the still state on touch and under reduced motion. On a fine
* pointer, dragging an invisible proxy with Draggable drives that same scroll position, so the wheel,
* the trackpad and focus keep working and there is only ever one source of truth.
*/
function InertiaCarousel({ className, children, "aria-label": label, ...props }: InertiaCarouselProps) {
const root = React.useRef<HTMLDivElement>(null);
const viewport = React.useRef<HTMLDivElement>(null);
const track = React.useRef<HTMLUListElement>(null);
const proxy = React.useRef<HTMLDivElement>(null);
const progress = React.useRef<HTMLDivElement>(null);
const live = React.useRef(false);
const target = React.useRef<number | null>(null);
const total = React.Children.count(children);
const [atStart, setAtStart] = React.useState(true);
const [atEnd, setAtEnd] = React.useState(false);
const [status, setStatus] = React.useState("");
/** Scroll position of each item, clamped to the end, so the last few share a stop. */
const stops = React.useCallback(() => {
const scroller = viewport.current;
const items = gsap.utils.toArray<HTMLElement>("[data-slot=inertia-carousel-item]", track.current);
if (!scroller || !items.length) return [0];
const max = scroller.scrollWidth - scroller.clientWidth;
const first = items[0].getBoundingClientRect().left;
return items.map((item) => Math.min(item.getBoundingClientRect().left - first, max));
}, []);
const measure = React.useCallback(() => {
const scroller = viewport.current;
if (!scroller) return;
const max = scroller.scrollWidth - scroller.clientWidth;
const at = scroller.scrollLeft;
// The bright part of the hairline is the share of the row in view, so it fills as you reach the end.
if (progress.current)
progress.current.style.transform = `scaleX(${(at + scroller.clientWidth) / scroller.scrollWidth})`;
setAtStart(at <= 1);
setAtEnd(at >= max - 1);
}, []);
const { contextSafe } = useGSAP(
() => {
measure();
window.addEventListener("resize", measure);
const media = gsap.matchMedia();
media.add(LIVE, () => {
const scroller = viewport.current;
if (!scroller || !proxy.current) return;
live.current = true;
const moved = proxy.current;
const limit = () => scroller.scrollWidth - scroller.clientWidth;
const sync = () => {
const max = limit();
const x = -Number(gsap.getProperty(moved, "x"));
scroller.scrollLeft = gsap.utils.clamp(0, max, x);
// Past either end the scroll position stops, so the track takes up the rubber band.
gsap.set(track.current, { x: x < 0 ? -x : x > max ? max - x : 0 });
};
const settle = () => {
gsap.set(track.current, { x: 0 });
scroller.style.scrollSnapType = "";
};
const [drag] = Draggable.create(moved, {
type: "x",
trigger: scroller,
inertia: true,
edgeResistance: 0.85,
dragClickables: true,
minimumMovement: 4,
maxDuration: 1.1,
minDuration: 0.3,
cursor: "grab",
activeCursor: "grabbing",
bounds: { minX: -limit(), maxX: 0 },
snap: {
x: (value: number) => {
const closest = gsap.utils.snap(stops());
return -closest(-value);
},
},
onPress(this: Draggable) {
gsap.killTweensOf(scroller);
target.current = null;
gsap.set(moved, { x: -scroller.scrollLeft });
this.applyBounds({ minX: -limit(), maxX: 0 });
this.update();
},
onDragStart: () => {
scroller.style.scrollSnapType = "none";
},
onDrag: sync,
onThrowUpdate: sync,
onThrowComplete: settle,
onClick: settle,
});
return () => {
live.current = false;
drag.kill();
settle();
gsap.set(track.current, { clearProps: "transform" });
};
});
return () => {
window.removeEventListener("resize", measure);
media.revert();
};
},
{ scope: root },
);
const go = contextSafe((left: number) => {
const scroller = viewport.current;
if (!scroller) return;
const all = stops();
setStatus(`Item ${all.indexOf(left) + 1} of ${all.length}`);
if (!live.current) {
scroller.scrollTo({ left, behavior: window.matchMedia(REDUCED).matches ? "auto" : "smooth" });
return;
}
target.current = left;
scroller.style.scrollSnapType = "none";
gsap.to(scroller, {
scrollLeft: left,
duration: 0.7,
ease: "power3.out",
overwrite: true,
onComplete: () => {
target.current = null;
scroller.style.scrollSnapType = "";
},
});
});
const step = (direction: 1 | -1) => {
const scroller = viewport.current;
if (!scroller) return;
// Measure from where a running tween is heading, so a quick double press moves two items.
const from = target.current ?? scroller.scrollLeft;
const all = stops();
const next = direction > 0 ? all.find((s) => s > from + 1) : all.findLast((s) => s < from - 1);
if (next !== undefined) go(next);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const all = stops();
if (event.key === "ArrowRight") step(1);
else if (event.key === "ArrowLeft") step(-1);
else if (event.key === "Home") go(all[0]);
else if (event.key === "End") go(all[all.length - 1]);
else return;
event.preventDefault();
};
return (
<div ref={root} data-slot="inertia-carousel" className={cn("relative min-w-0", className)} {...props}>
<div ref={proxy} aria-hidden="true" className="absolute size-0" />
<div
ref={viewport}
role="region"
aria-label={label}
aria-roledescription="carousel"
tabIndex={0}
onKeyDown={onKeyDown}
onScroll={measure}
className="snap-x snap-mandatory scroll-px-6 [scrollbar-width:none] overflow-x-auto md:scroll-px-8 [&::-webkit-scrollbar]:hidden"
>
<ul ref={track} className="m-0 flex w-max list-none gap-3 px-6 py-0 md:px-8">
{children}
</ul>
</div>
<div className="mt-6 flex items-center gap-6 px-6 md:px-8">
<div aria-hidden="true" className="h-px flex-1 bg-border">
<div
ref={progress}
className="h-px origin-left bg-foreground"
style={{ transform: `scaleX(${1 / Math.max(total, 1)})` }}
/>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
className="rounded-full aria-disabled:cursor-default aria-disabled:opacity-40"
aria-label="Previous item"
aria-disabled={atStart}
onClick={() => step(-1)}
>
<ArrowLeft />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="rounded-full aria-disabled:cursor-default aria-disabled:opacity-40"
aria-label="Next item"
aria-disabled={atEnd}
onClick={() => step(1)}
>
<ArrowRight />
</Button>
</div>
</div>
<p className="sr-only" aria-live="polite">
{status}
</p>
</div>
);
}
/** One item. Set its width with className, the default is most of a phone and 36rem above that. */
function InertiaCarouselItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="inertia-carousel-item"
className={cn("w-[78vw] shrink-0 snap-start md:w-[36rem]", className)}
{...props}
/>
);
}
export { InertiaCarousel, InertiaCarouselItem };
For AI agents
Hand your agent this page, the markdown at /components/inertia-carousel/prompt.md, or the Copy AI prompt button above.
When to use
- A short set of visual items that reward browsing, such as case studies or renders
- Full-bleed rows on a marketing page
- When people should feel the weight of what they are moving
When not to use
- Content people need to compare or read in full: use a grid
- One item at a time on a timer: this never moves by itself
- Inside a parent with a CSS transform, which throws off the measured stops
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- It is a real scroller at every size. Draggable moves a hidden proxy and writes to scrollLeft, so wheel, trackpad and tabbing into items are native
- Dragging and tweening run only on a fine pointer with motion allowed. Touch and reduced motion get native scroll and CSS snap
- Arrow keys, Home and End work while the region or anything in it has focus. Focusable items are fine
The maths
- Each stop is min(item left - first left, scrollWidth - clientWidth), so the last items share a stop instead of overscrolling.
- Edge resistance 0.85 means a drag past the end moves the track 15 percent of the distance.
- The progress hairline is scaleX((scrollLeft + clientWidth) / scrollWidth), so it starts at the share in view and never reflows.