Tone sliders
Five sliders that share one anatomy and vary only the semantic tone: info, success, warning, danger and muted. The track is a color-mix wash of the same token that fills it, every root carries data-tone, and each thumb keeps role slider with full keyboard control.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/slider-toneDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/slider-tone.tsx"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Slider tone family: 5 semantically toned sliders (info / success / warning /
danger / muted). A self-sufficient structure: a track div + a fill div + a thumb
button. Pointer dragging (clientX within the track rect, with capture on the
track) AND keyboard (arrows, Shift+arrow for a large step, Home/End). Color comes
ONLY from tokens, via alpha color-mix. Every root carries data-tone; the invalid
appearance uses the ai2 danger token. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Tone = "info" | "success" | "warning" | "danger" | "muted"
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 rootBase =
"relative flex w-56 max-w-full touch-none select-none items-center outline-none"
/* Basparmak 24px altinda: gorunmez dokunma alani genisletmesi zorunlu. */
const thumbBase =
"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background shadow-sm outline-none after:absolute after:-inset-1.5 after:content-[''] focus-visible:ring-[3px] focus-visible:ring-ring/50"
const toneFill: Record<Tone, string> = {
info: "bg-info",
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
muted: "bg-muted-foreground",
}
const toneThumb: Record<Tone, string> = {
info: "bg-info",
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
muted: "bg-muted-foreground",
}
/* The track is a very low-intensity form of the tone (alpha via color-mix only). */
const toneTrack: Record<Tone, string> = {
info: "[background-color:color-mix(in_oklab,var(--info)_18%,transparent)]",
success: "[background-color:color-mix(in_oklab,var(--success)_18%,transparent)]",
warning: "[background-color:color-mix(in_oklab,var(--warning)_18%,transparent)]",
danger: "[background-color:color-mix(in_oklab,var(--danger)_18%,transparent)]",
muted: "bg-secondary",
}
export type ToneSliderProps = {
className?: string
size?: StyledSize
min?: number
max?: number
step?: number
value?: number
defaultValue?: number
onValueChange?: (v: number) => void
}
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
/* Shared horizontal slider behaviour: controlled/uncontrolled value, pointer,
keyboard. */
function useSlider(props: ToneSliderProps) {
const {
value,
defaultValue = 55,
onValueChange,
min = 0,
max = 100,
step = 1,
} = 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()
trackRef.current?.setPointerCapture?.(e.pointerId)
setDragging(true)
commit(fromClientX(e.clientX))
},
[commit, fromClientX]
)
const onThumbPointerDown = React.useCallback((e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
trackRef.current?.setPointerCapture?.(e.pointerId)
setDragging(true)
}, [])
const onPointerMove = React.useCallback(
(e: React.PointerEvent) => {
if (!dragging) return
commit(fromClientX(e.clientX))
},
[commit, dragging, fromClientX]
)
const onPointerUp = React.useCallback((e: React.PointerEvent) => {
trackRef.current?.releasePointerCapture?.(e.pointerId)
setDragging(false)
}, [])
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
const delta = e.shiftKey ? step * 10 : step
let next = current
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = current - delta
else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = current + delta
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,
onThumbPointerDown,
onPointerMove,
onPointerUp,
onKeyDown,
}
}
/* A single body: only the tone classes change. The state lives in this component and
is never inside a conditional subtree. */
function SemanticSlider({
tone,
label,
className,
size = "md",
...rest
}: ToneSliderProps & { tone: Tone; label: string }) {
const reduce = useReducedMotion()
const s = useSlider(rest)
return (
<div
data-slot="styled-slider"
data-tone={tone}
className={cn(rootBase, className)}
>
<div
ref={s.trackRef}
onPointerDown={s.onPointerDown}
onPointerMove={s.onPointerMove}
onPointerUp={s.onPointerUp}
className={cn(
"relative w-full cursor-pointer rounded-full",
toneTrack[tone],
trackHeight[size]
)}
>
<div
className={cn("absolute inset-y-0 left-0 rounded-full", toneFill[tone])}
style={{ width: `${s.pct}%` }}
/>
<motion.button
type="button"
data-slot="styled-slider-thumb"
role="slider"
aria-label={label}
aria-valuenow={Math.round(s.current)}
aria-valuemin={s.min}
aria-valuemax={s.max}
aria-orientation="horizontal"
tabIndex={0}
onPointerDown={s.onThumbPointerDown}
onKeyDown={s.onKeyDown}
className={cn(thumbBase, thumbSize[size], toneThumb[tone])}
style={{ left: `${s.pct}%` }}
animate={{ scale: !reduce && s.dragging ? 1.18 : 1 }}
transition={
reduce
? { duration: 0 }
: { type: "spring" as const, stiffness: 500, damping: 30, mass: 0.6 }
}
/>
</div>
</div>
)
}
/* Info: bilgilendirici mavi ton. */
export function InfoSlider(props: ToneSliderProps) {
return <SemanticSlider tone="info" label="Info value" {...props} />
}
/* Success: olumlu yesil ton. */
export function SuccessSlider(props: ToneSliderProps) {
return <SemanticSlider tone="success" label="Success value" {...props} />
}
/* Warning: uyari tonu. */
export function WarningSlider(props: ToneSliderProps) {
return <SemanticSlider tone="warning" label="Warning value" {...props} />
}
/* Danger: only the ai2 danger token is used. */
export function DangerSlider(props: ToneSliderProps) {
return <SemanticSlider tone="danger" label="Danger value" {...props} />
}
/* Muted: notr, dusuk vurgulu ton. */
export function MutedSlider(props: ToneSliderProps) {
return <SemanticSlider tone="muted" label="Value" {...props} />
}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.
Info
The info tone for neutral, informational values.
import { InfoSlider } from "@/components/ui/slider-tone"
<InfoSlider />Success
The success tone for healthy or completed values.
import { SuccessSlider } from "@/components/ui/slider-tone"
<SuccessSlider />Warning
The warning tone for values that need attention.
import { WarningSlider } from "@/components/ui/slider-tone"
<WarningSlider />Danger
The ai2 danger tone for risky or destructive values.
import { DangerSlider } from "@/components/ui/slider-tone"
<DangerSlider />Muted
A quiet neutral tone for secondary controls.
import { MutedSlider } from "@/components/ui/slider-tone"
<MutedSlider />ai2 Tone sliders: 5 styled variations on the token system
The ai2 Tone sliders are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around sliders colored by semantic tone. 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 while dragging; 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 Tone sliders?
5 exports in one file: Info, Success, Warning, Danger and Muted. 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 while dragging; 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 Tone sliders 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.