A showcase frame that starts tilted back in 3D and flattens to face the reader as it scrolls into view, with a title that lifts alongside.
Dashboard
Everything in one calm view
Revenue
$48.2k
Active users
2,910
Conversion
4.6%
"use client";
import * as React from "react";
import { ContainerScroll } from "@/components/ballmac/container-scroll";
export default function ContainerScrollDemo() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div ref={scroller} role="region" tabIndex={0} aria-label="Product showcase" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-[26rem] w-full max-w-3xl overflow-auto rounded-xl border bg-background shadow-sm">
<div className="h-16" aria-hidden="true" />
<ContainerScroll
container={scroller}
title={
<>
<p className="text-sm font-medium text-primary">Dashboard</p>
<h2 className="mt-1 text-3xl font-semibold tracking-tight">Everything in one calm view</h2>
</>
}
>
<div className="grid gap-3 p-4 sm:grid-cols-3">
{[["Revenue", "$48.2k"], ["Active users", "2,910"], ["Conversion", "4.6%"]].map(([k, v]) => (
<div key={k} className="rounded-xl border bg-card p-4">
<p className="text-xs text-muted-foreground">{k}</p>
<p className="text-2xl font-semibold tabular-nums">{v}</p>
</div>
))}
<div className="col-span-full flex h-28 items-end gap-2 rounded-xl border bg-card p-4">
{[35, 55, 42, 70, 62, 88, 76, 95].map((h, i) => (
<div key={i} className="flex-1 rounded-t bg-primary/80" style={{ height: `${h}%` }} />
))}
</div>
</div>
</ContainerScroll>
<div className="h-40" aria-hidden="true" />
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/container-scrollInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/container-scroll.tsx// Ballmac UI: Container Scroll. https://ui.ballmac.com/components/container-scroll "use client"; import * as React from "react"; import { motion, useReducedMotion, useScroll, useTransform } from "motion/react"; import { cn } from "@/lib/utils"; type ContainerScrollProps = Omit<React.ComponentProps<"div">, "title"> & { /** Heading above the frame. It slides up slightly as the frame flattens. */ title?: React.ReactNode; /** A scrollable element to track instead of the page. */ container?: React.RefObject<HTMLElement | null>; /** Starting tilt of the frame in degrees. */ tilt?: number; /** Classes for the frame that holds the children. */ frameClassName?: string; }; /** * A showcase frame that starts tilted back in 3D and flattens to face the reader as it scrolls into view. * Put a screenshot, video or live product demo inside. Under reduced motion the frame is flat from the start. */ function ContainerScroll({ title, container, tilt = 22, frameClassName, className, children, ...props }: ContainerScrollProps) { const reduce = useReducedMotion(); const ref = React.useRef<HTMLDivElement>(null); const { scrollYProgress } = useScroll({ target: ref, container, offset: ["start end", "center center"], }); const rotate = useTransform(scrollYProgress, [0, 1], [tilt, 0]); const scale = useTransform(scrollYProgress, [0, 1], [0.92, 1]); const lift = useTransform(scrollYProgress, [0, 1], [0, -24]); return ( <div ref={ref} data-slot="container-scroll" className={cn("flex w-full flex-col items-center gap-8 py-10 [perspective:1100px]", className)} {...props} > {title && ( <motion.div style={reduce ? undefined : { y: lift }} className="max-w-2xl text-center text-balance"> {title} </motion.div> )} <motion.div data-slot="container-scroll-frame" style={reduce ? undefined : { rotateX: rotate, scale, transformOrigin: "50% 100%" }} className={cn( "w-full max-w-4xl overflow-hidden rounded-[1.75rem] border bg-card p-2 shadow-[0_0_0_1px_rgb(0_0_0/0.03),0_30px_80px_-30px_rgb(0_0_0/0.35),0_12px_24px_-12px_rgb(0_0_0/0.15)] sm:p-3", frameClassName, )} > <div className="overflow-hidden rounded-[1.25rem] border bg-background">{children}</div> </motion.div> </div> ); } export { ContainerScroll, type ContainerScrollProps };Update the import paths to match your project setup.
Usage
import { ContainerScroll } from "@/components/ballmac/container-scroll"The full example is in the Code tab above.
Examples
Steeper tilt
Steeper tilt
"use client";
import * as React from "react";
import { ContainerScroll } from "@/components/ballmac/container-scroll";
export default function ContainerScrollStates() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div ref={scroller} role="region" tabIndex={0} aria-label="Showcase" className="outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 h-72 w-full max-w-sm overflow-auto rounded-xl border bg-background">
<div className="h-10" aria-hidden="true" />
<ContainerScroll container={scroller} tilt={35} title={<h2 className="text-xl font-semibold">Steeper tilt</h2>} className="px-4">
<div className="grid h-32 place-items-center text-sm text-muted-foreground">Any content: video, screenshot, live demo</div>
</ContainerScroll>
<div className="h-32" aria-hidden="true" />
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
titleHeading above the frame. It slides up slightly as the frame flattens. | React.ReactNode | — |
containerA scrollable element to track instead of the page. | React.RefObject<HTMLElement | null> | — |
tiltStarting tilt of the frame in degrees. | number | 22 |
frameClassNameClasses for the frame that holds the children. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Reduced motion | The frame is flat from the start, with no transform |
| Content | Children stay real, selectable, focusable content |
Use with AI
<ContainerScroll title={...}>{screenshot or live demo}</ContainerScroll>. tilt sets the starting angle; container tracks a scrollable element. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Container Scroll (@ballmac/container-scroll) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Product hero sections
- Showing a dashboard or app screenshot
Not for
- Content that must stay flat and readable at all times
- Pages that scroll inside nested containers without passing container
Registry JSON: https://ui.ballmac.com/r/container-scroll.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Browser Frame
A Safari-style window with traffic lights, a padlocked address field and optional tabs. Children render as the page, either at natural size or laid out at desktop width and scaled to fit.
Sticky Scroll
Scroll-driven storytelling: text steps scroll past while one visual stays pinned and crossfades to match, with each visual inline on small screens.
Bento Grid
A responsive bento layout of feature cards that span columns and rows. Each BentoCard has a background visual slot, icon, title and description, and a link that slides up on hover or focus.
App Shell
The frame of an application page: skip link, sticky header, sidebar that becomes a sheet on small screens, main area, optional aside and footer.