Styled slider
Five slider treatments: a gradient fill, a glow thumb, step ticks, a value bubble and a thick track. Each is sized, token-driven and keyboard accessible with role slider.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/slider-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/slider-styled.tsx"use client"
import * as React from "react"
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react"
import { cn } from "@/lib/utils"
/* Slider family: 5 decorative single-thumb sliders. A custom accessible structure
(a native input[type=range] thumb cannot take tokens): a track div + a fill div +
a thumb button. Pointer dragging (clientX within the track/thumb rect) AND
keyboard (ArrowLeft/Right). Color comes ONLY from tokens, via alpha color-mix.
The thumb spring is disabled under reduced-motion. Controlled (value) or
uncontrolled (defaultValue). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const trackHeight: Record<StyledSize, string> = {
sm: "h-1",
md: "h-1.5",
lg: "h-2.5",
xl: "h-3.5",
}
const thumbSize: Record<StyledSize, string> = {
sm: "size-3.5",
md: "size-4",
lg: "size-5",
xl: "size-6",
}
const thickTrackHeight: Record<StyledSize, string> = {
sm: "h-2.5",
md: "h-3.5",
lg: "h-5",
xl: "h-6",
}
const thickThumbSize: Record<StyledSize, string> = {
sm: "size-5",
md: "size-6",
lg: "size-7",
xl: "size-9",
}
const rootBase =
"relative flex w-56 max-w-full touch-none select-none items-center outline-none"
/* The thumb's visual box stays under 24px (sm 14px ... xl 24px), so per AGENTS.md R48 it carries an invisible hit-area extension: after:-inset-1.5. The extra area only grows the touch target, the visual size does not change. The same pattern as the base ui/slider.tsx. */
const thumbBase =
"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full outline-none after:absolute after:-inset-1.5 focus-visible:ring-[3px] focus-visible:ring-ring/50"
type SliderProps = {
className?: string
size?: StyledSize
value?: number
defaultValue?: number
onValueChange?: (v: number) => void
min?: number
max?: number
}
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
/* Shared slider behaviour: controlled/uncontrolled value, pointer dragging, keyboard. `pct` is the fill percentage between 0 and 100. */
function useSlider(props: SliderProps, step = 1) {
const { value, defaultValue = 40, onValueChange, min = 0, max = 100 } = props
const trackRef = React.useRef<HTMLDivElement>(null)
const [internal, setInternal] = React.useState(clamp(defaultValue, min, max))
const [dragging, setDragging] = React.useState(false)
const current = value !== undefined ? clamp(value, min, max) : internal
const commit = React.useCallback(
(next: number) => {
const c = clamp(Math.round(next / step) * step, min, max)
if (value === undefined) setInternal(c)
onValueChange?.(c)
},
[max, min, onValueChange, step, value]
)
const fromClientX = React.useCallback(
(clientX: number) => {
const el = trackRef.current
if (!el) return current
const rect = el.getBoundingClientRect()
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0
return min + clamp(ratio, 0, 1) * (max - min)
},
[current, max, min]
)
const onPointerDown = React.useCallback(
(e: React.PointerEvent) => {
e.preventDefault()
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
setDragging(true)
commit(fromClientX(e.clientX))
},
[commit, fromClientX]
)
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (!dragging) return
commit(fromClientX(e.clientX))
},
[commit, dragging, fromClientX]
)
const onPointerUp = React.useCallback(
(e: React.PointerEvent) => {
;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
setDragging(false)
},
[]
)
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
let next = current
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = current - step
else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = current + step
else if (e.key === "Home") next = min
else if (e.key === "End") next = max
else return
e.preventDefault()
commit(next)
},
[commit, current, max, min, step]
)
const pct = max > min ? ((current - min) / (max - min)) * 100 : 0
return {
trackRef,
current,
pct,
dragging,
min,
max,
onPointerDown,
onPointerMove,
onPointerUp,
onKeyDown,
}
}
/* The shared position value that follows the thumb with a spring (instant under reduced-motion). */
function useThumbSpring(pct: number, reduce: boolean | null) {
const raw = useMotionValue(pct)
const spring = useSpring(raw, { stiffness: 500, damping: 40, mass: 0.6 })
React.useEffect(() => {
raw.set(pct)
}, [pct, raw])
return reduce ? pct : spring
}
type RootHandlers = ReturnType<typeof useSlider>
function rootAria(s: RootHandlers) {
return {
role: "slider" as const,
"aria-valuenow": Math.round(s.current),
"aria-valuemin": s.min,
"aria-valuemax": s.max,
"aria-orientation": "horizontal" as const,
}
}
/* Gradient: dolum cok tonlu token gradyan. */
export function GradientSlider({ className, size = "md", ...rest }: SliderProps) {
const reduce = useReducedMotion()
const s = useSlider({ size, ...rest })
const left = useThumbSpring(s.pct, reduce)
return (
<div
data-slot="styled-slider"
{...rootAria(s)}
className={cn(rootBase, className)}
onPointerMove={s.onPointerMove}
onPointerUp={s.onPointerUp}
>
<div
ref={s.trackRef}
onPointerDown={s.onPointerDown}
className={cn("relative w-full cursor-pointer overflow-hidden rounded-full bg-secondary", trackHeight[size])}
>
<div
className="absolute inset-y-0 left-0 rounded-full [background-image:linear-gradient(90deg,var(--info),var(--brand),var(--success))]"
style={{ width: `${s.pct}%` }}
/>
</div>
<motion.button
type="button"
data-slot="styled-slider-thumb"
onPointerDown={s.onPointerDown}
onKeyDown={s.onKeyDown}
className={cn(thumbBase, thumbSize[size], "border-2 border-background bg-primary shadow-sm")}
style={{ left: typeof left === "number" ? `${left}%` : (left as unknown as string) }}
/>
</div>
)
}
/* Glow: basparmakta token isima, surukleme sirasinda siddetlenir. */
export function GlowSlider({ className, size = "md", ...rest }: SliderProps) {
const reduce = useReducedMotion()
const s = useSlider({ size, ...rest })
const left = useThumbSpring(s.pct, reduce)
return (
<div
data-slot="styled-slider"
{...rootAria(s)}
className={cn(rootBase, className)}
onPointerMove={s.onPointerMove}
onPointerUp={s.onPointerUp}
>
<div
ref={s.trackRef}
onPointerDown={s.onPointerDown}
className={cn("relative w-full cursor-pointer overflow-hidden rounded-full bg-secondary", trackHeight[size])}
>
<div className="absolute inset-y-0 left-0 rounded-full bg-info" style={{ width: `${s.pct}%` }} />
</div>
<motion.button
type="button"
data-slot="styled-slider-thumb"
onPointerDown={s.onPointerDown}
onKeyDown={s.onKeyDown}
className={cn(
thumbBase,
thumbSize[size],
"border-2 border-background bg-info transition-shadow",
s.dragging ? "shadow-[0_0_16px_3px_var(--info)]" : "shadow-[0_0_8px_1px_var(--info)]"
)}
style={{ left: typeof left === "number" ? `${left}%` : (left as unknown as string) }}
/>
</div>
)
}
/* Ticks: step lines along the track; snapping (step) is optional. */
export function TicksSlider({ className, size = "md", ...rest }: SliderProps) {
const reduce = useReducedMotion()
const step = 10
const s = useSlider({ size, ...rest }, step)
const left = useThumbSpring(s.pct, reduce)
const ticks = 11
return (
<div
data-slot="styled-slider"
{...rootAria(s)}
className={cn(rootBase, className)}
onPointerMove={s.onPointerMove}
onPointerUp={s.onPointerUp}
>
<div
ref={s.trackRef}
onPointerDown={s.onPointerDown}
className={cn("relative w-full cursor-pointer rounded-full bg-secondary", trackHeight[size])}
>
<div className="absolute inset-y-0 left-0 rounded-full bg-primary" style={{ width: `${s.pct}%` }} />
<div className="pointer-events-none absolute inset-0">
{Array.from({ length: ticks }).map((_, i) => (
<span
key={i}
className="absolute top-1/2 h-1.5 w-px -translate-x-1/2 -translate-y-1/2 rounded-full bg-border"
style={{ left: `${(i / (ticks - 1)) * 100}%` }}
/>
))}
</div>
</div>
<motion.button
type="button"
data-slot="styled-slider-thumb"
onPointerDown={s.onPointerDown}
onKeyDown={s.onKeyDown}
className={cn(thumbBase, thumbSize[size], "border-2 border-background bg-primary shadow-sm")}
style={{ left: typeof left === "number" ? `${left}%` : (left as unknown as string) }}
/>
</div>
)
}
/* Bubble: a value bubble appears above the thumb while dragging. */
export function BubbleSlider({ className, size = "md", ...rest }: SliderProps) {
const reduce = useReducedMotion()
const s = useSlider({ size, ...rest })
const left = useThumbSpring(s.pct, reduce)
return (
<div
data-slot="styled-slider"
{...rootAria(s)}
className={cn(rootBase, className)}
onPointerMove={s.onPointerMove}
onPointerUp={s.onPointerUp}
>
<div
ref={s.trackRef}
onPointerDown={s.onPointerDown}
className={cn("relative w-full cursor-pointer overflow-hidden rounded-full bg-secondary", trackHeight[size])}
>
<div className="absolute inset-y-0 left-0 rounded-full bg-primary" style={{ width: `${s.pct}%` }} />
</div>
<motion.button
type="button"
data-slot="styled-slider-thumb"
onPointerDown={s.onPointerDown}
onKeyDown={s.onKeyDown}
className={cn(thumbBase, thumbSize[size], "border-2 border-background bg-primary shadow-sm")}
style={{ left: typeof left === "number" ? `${left}%` : (left as unknown as string) }}
>
<motion.span
data-slot="styled-slider-bubble"
aria-hidden="true"
className="pointer-events-none absolute -top-2 left-1/2 -translate-x-1/2 -translate-y-full rounded-md bg-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary-foreground shadow-sm"
initial={false}
animate={reduce ? { opacity: s.dragging ? 1 : 0 } : { opacity: s.dragging ? 1 : 0, y: s.dragging ? 0 : 4 }}
transition={reduce ? { duration: 0 } : { duration: 0.15, ease: "easeOut" }}
>
{Math.round(s.current)}
</motion.span>
</motion.button>
</div>
)
}
/* Thick: iri yuvarlatilmis iz + buyuk basparmak. */
export function ThickSlider({ className, size = "md", ...rest }: SliderProps) {
const reduce = useReducedMotion()
const s = useSlider({ size, ...rest })
const left = useThumbSpring(s.pct, reduce)
return (
<div
data-slot="styled-slider"
{...rootAria(s)}
className={cn(rootBase, "min-h-9", className)}
onPointerMove={s.onPointerMove}
onPointerUp={s.onPointerUp}
>
<div
ref={s.trackRef}
onPointerDown={s.onPointerDown}
className={cn("relative w-full cursor-pointer overflow-hidden rounded-full bg-secondary", thickTrackHeight[size])}
>
<div
className="absolute inset-y-0 left-0 rounded-full bg-primary"
style={{ width: `${s.pct}%` }}
/>
</div>
<motion.button
type="button"
data-slot="styled-slider-thumb"
onPointerDown={s.onPointerDown}
onKeyDown={s.onKeyDown}
className={cn(thumbBase, thickThumbSize[size], "border-4 border-background bg-primary shadow-md")}
style={{ left: typeof left === "number" ? `${left}%` : (left as unknown as string) }}
/>
</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.
Gradient
The fill is a token gradient.
import { GradientSlider } from "@/components/ui/slider-styled"
<GradientSlider />Glow
The thumb glows, brighter while dragging.
import { GlowSlider } from "@/components/ui/slider-styled"
<GlowSlider />Ticks
The track shows step ticks.
import { TicksSlider } from "@/components/ui/slider-styled"
<TicksSlider />Bubble
A value bubble appears above the thumb while dragging.
import { BubbleSlider } from "@/components/ui/slider-styled"
<BubbleSlider />Thick
A chunky rounded track with a large thumb.
import { ThickSlider } from "@/components/ui/slider-styled"
<ThickSlider />ai2 Styled slider: 5 styled variations on the token system
The ai2 Styled slider are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around range slider treatments. 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 springs the thumb; pointer drag and keyboard both work. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the thumb spring is disabled and it moves directly.
What is in the ai2 Styled slider?
5 exports in one file: Gradient, Glow, Ticks, Bubble and Thick. 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 springs the thumb; pointer drag and keyboard both work.
- Reduced-motion aware: Under prefers-reduced-motion, the thumb spring is disabled and it moves directly.
- 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 Styled slider 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.