3D progress
Five depth treatments: raised, tube, bevel, gloss and inset. Each is sized and token-driven, with a value prop.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/progress-threedDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/progress-threed.tsx"use client"
import type * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* 3D progress family: 5 fills carrying a depth illusion (highlight plus shadow). Colour comes ONLY from tokens (alpha via color-mix, oklab). value is 0-100, clamped. framer-motion drives the fill width; the fill settles instantly under reduced-motion. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const track: Record<StyledSize, string> = {
sm: "h-1",
md: "h-1.5",
lg: "h-2.5",
xl: "h-3.5",
}
const trackBase = "relative w-56 max-w-full overflow-hidden rounded-full bg-secondary"
type Props = React.ComponentProps<"div"> & { size?: StyledSize; value?: number; label?: string }
function clamp(v: number) {
return Math.max(0, Math.min(100, v))
}
/* Raised: dolgu ustte highlight, altta golge ile kabarik gorunur. */
export function RaisedProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn(trackBase, track[size], className)}
{...props}
>
<motion.span
className="absolute inset-y-0 left-0 rounded-full bg-primary shadow-[inset_0_1.5px_0_color-mix(in_oklab,var(--color-primary-foreground)_45%,transparent),inset_0_-2px_2px_color-mix(in_oklab,var(--color-foreground)_30%,transparent)]"
initial={reduce ? false : { width: 0 }}
animate={{ width: `${v}%` }}
transition={reduce ? undefined : { duration: 0.8, ease: "easeOut" }}
/>
</div>
)
}
/* Tube: the impression of a cylinder/tube - a vertical light-dark token gradient
over the fill. */
export function TubeProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn(trackBase, track[size], className)}
{...props}
>
<motion.span
className="absolute inset-y-0 left-0 rounded-full bg-primary [background-image:linear-gradient(to_bottom,color-mix(in_oklab,var(--color-primary-foreground)_42%,transparent)_0%,transparent_45%,color-mix(in_oklab,var(--color-foreground)_28%,transparent)_100%)]"
initial={reduce ? false : { width: 0 }}
animate={{ width: `${v}%` }}
transition={reduce ? undefined : { duration: 0.8, ease: "easeOut" }}
/>
</div>
)
}
/* Bevel: capraz inset highlight/golge ile pahli (beveled) kenar. */
export function BevelProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn("relative w-56 max-w-full overflow-hidden rounded-sm bg-secondary", track[size], className)}
{...props}
>
<motion.span
className="absolute inset-y-0 left-0 rounded-sm bg-primary shadow-[inset_1.5px_1.5px_0_color-mix(in_oklab,var(--color-primary-foreground)_40%,transparent),inset_-1.5px_-1.5px_0_color-mix(in_oklab,var(--color-foreground)_32%,transparent)]"
initial={reduce ? false : { width: 0 }}
animate={{ width: `${v}%` }}
transition={reduce ? undefined : { duration: 0.8, ease: "easeOut" }}
/>
</div>
)
}
/* Gloss: dolgunun ustunde parlak cam serit (ust yariyi kaplayan token highlight). */
export function GlossProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn(trackBase, track[size], className)}
{...props}
>
<motion.span
className="absolute inset-y-0 left-0 overflow-hidden rounded-full bg-primary"
initial={reduce ? false : { width: 0 }}
animate={{ width: `${v}%` }}
transition={reduce ? undefined : { duration: 0.8, ease: "easeOut" }}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-1/2 rounded-t-full [background-image:linear-gradient(to_bottom,color-mix(in_oklab,var(--color-primary-foreground)_55%,transparent),transparent)]"
/>
</motion.span>
</div>
)
}
/* Inset: the track looks carved and the fill sits raised on top of it. */
export function InsetProgress({ className, size = "md", value = 60, label = "Progress", ...props }: Props) {
const reduce = useReducedMotion()
const v = clamp(value)
return (
<div
data-slot="styled-progress"
role="progressbar"
aria-valuenow={v}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={cn(
trackBase,
track[size],
"shadow-[inset_0_1.5px_2px_color-mix(in_oklab,var(--color-foreground)_25%,transparent)]",
className
)}
{...props}
>
<motion.span
className="absolute inset-y-0 left-0 rounded-full bg-primary shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-primary-foreground)_40%,transparent),0_1px_2px_color-mix(in_oklab,var(--color-foreground)_25%,transparent)]"
initial={reduce ? false : { width: 0 }}
animate={{ width: `${v}%` }}
transition={reduce ? undefined : { duration: 0.8, ease: "easeOut" }}
/>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Raised
A raised fill with a top highlight and bottom shadow.
import { RaisedProgress } from "@/components/ui/progress-threed"
<RaisedProgress value={60} />Tube
A rounded 3D tube look.
import { TubeProgress } from "@/components/ui/progress-threed"
<TubeProgress value={60} />Bevel
Beveled edges give a carved look.
import { BevelProgress } from "@/components/ui/progress-threed"
<BevelProgress value={60} />Gloss
A glossy highlight strip on the fill.
import { GlossProgress } from "@/components/ui/progress-threed"
<GlossProgress value={60} />Inset
A carved track with a raised fill.
import { InsetProgress } from "@/components/ui/progress-threed"
<InsetProgress value={60} />ai2 3D progress: 5 styled variations on the token system
The ai2 3D progress are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around depth-styled progress bars. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion animates the fill width to the value. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the fill lands instantly with no animation.
What is in the ai2 3D progress?
5 exports in one file: Raised, Tube, Bevel, Gloss and Inset. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion animates the fill width to the value.
- Reduced-motion aware: Under prefers-reduced-motion, the fill lands instantly with no animation.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The 3D progress sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.