Open raviteja83 opened 2 years ago
Hey @raviteja83,
I believe this was a feature on our list for Slider
when we were building it, but we dropped it so we could release a simple version first. I am sure we will get back to that at some point though.
✌️
We probably won't do this anytime soon and would rather focus on producing more primitives. This is also probably possible in user-land.
@benoitgrelard just a heads up that i'm not sure if this is that easy user-land because there are offset calculations on the thumb to stop it from overflowing the edges of the range and consumers don't have access to that offset to align the points.
perhaps css grid could help tho? maybe an example in the docs would help if so.
That's a great point @jjenzz, this might need to be offered as part of the component for that reason then.
I hope marks will be added soon, I had a hard time making them myself
But no, I was joking, I didn’t manage to do it myself until the end, only if the number of marks is equal to the maximum value
Any updates on that? 👀
In case this is slated for (Far in the Future) -- a blog post/stackoverflow post about how this was made would definitely be enough for most people (in React/NextJS hopefully)!
Here's a working example that the Radix team might have access to?
https://workos.com/pricing (Workos.com is the maintainer of RadixUI in case anybody is wondering.)
One very small point of feedback on this implementation ^ there is a tiny bit of jitter on cursor turning from arrow/hand when using the slider -- perhaps, adding hover:cursor-pointer in a more parent element will address issue.
Hey @ChungMasterFlex, I think we're clear on what the issue is about. Regarding the example above from the WorkOS pricing page, that doesn't use Radix and is actually all done in Webflow for the marketing pages.
For those looking for a temporary workaround, you can just mimic Radix' calculation to get the same positions as those the thumb can be at.
// Positioned mark without anything displayed, you can add classes, children etc.
// I'm using Tailwind here, but replace with X
// maxIndex should be 9 if you have 10 steps
// stepIndex controls the position you want to display the mark at.
<div
className={clsx("absolute", orientation === "horizontal" ? "-translate-x-1/2" : "-translate-y-1/2")}
style={{ [orientation === "horizontal" ? "left" : "top"]: calcStepMarkOffset(stepIndex, maxIndex) }}
/>
// Then you need these 4 functions I copied almost 1-1 from Radix
// Assuming you know thumb size at coding-time or have a way to measure it.
const THUMB_SIZE = 32;
function calcStepMarkOffset(index: number, maxIndex: number) {
const percent = convertValueToPercentage(index, 0, maxIndex);
const thumbInBoundsOffset = getThumbInBoundsOffset(THUMB_SIZE, percent, 1);
return `calc(${percent}% + ${thumbInBoundsOffset}px)`;
}
function convertValueToPercentage(value: number, min: number, max: number) {
const maxSteps = max - min;
const percentPerStep = 100 / maxSteps;
const percentage = percentPerStep * (value - min);
return clamp(percentage, { max: 100, min: 0 });
}
function getThumbInBoundsOffset(width: number, left: number, direction: number) {
const halfWidth = width / 2;
const halfPercent = 50;
const offset = linearScale([0, halfPercent], [0, halfWidth]);
return (halfWidth - offset(left) * direction) * direction;
}
function linearScale(input: readonly [number, number], output: readonly [number, number]) {
return (value: number) => {
if (input[0] === input[1] || output[0] === output[1]) return output[0];
const ratio = (output[1] - output[0]) / (input[1] - input[0]);
return output[0] + ratio * (value - input[0]);
};
}
A real implementation might then look similar to this workaround:
// orientation, max etc. need to be passed in the workaround,
// but a real implementation would inherit them from Slider.Root
<Slider.Mark step={step}>
{someText}
</Slider.Mark>
Need one these man
Here's also another solution that I came up with: https://codesandbox.io/p/sandbox/divine-http-93zqyd
My use case was that I needed those specific points be equal to specific values (e.g. 18, 25, 35, 45, 55, 65) and wanted user to able to smoothly drag the thumb while only allowing those specific values to be selected.
I mapped those values to integers 0...onValueChange
handler to update state whenever value
is updated by rounding the current value to nearest integer:
onValueChange={(newValues) => {
const roundedValue = Math.round(newValues[0]);
setMappedValues([valueMapping[roundedValue]]);
}}
and update state (mappedValue
):
const [mappedValues, setMappedValues] = useState(() =>[
valueMapping[Math.round(props?.defaultValue[0] ?? 0)]
]);
(Same works for two thumbs, which was my actual use case)
considering using this slider but missing this feature. is it possible in 2023?
2024 need this feature
Could definitely use this - I think it's universal enough need (that benefits from access to internals) to be considered part of the primitive itself rather than a user-land implementation
Up
up
Up
Up
Up
Up
Up
Up
UPPPP !
UUUPPPP!! 💯
Up
Up
Up
Up 🔝
Up 0_o
upppp!!!!
upppppppppp 🆙🆙🆙🆙
Just for your information: Subscribed people receive an email, every time you spam this thread. I'd rather not unsubscribe, because I'd like to be informed, when somebody posts anything valuable - for example another workaround or a suggestion for a PR. Beyond that, I assume, aggressively shouting upppp multiple times won't premove contributers to act, neither.
Definitely looking forward to this being part of the primitive itself, but for now I've rolled-in a pretty simple workaround to my project and figured I'd share:
I'm using shadcn-ui
which usesradix
under the hood, and added this snippet underneath my local Slider component which is a wrapper around the shadcn-ui
and radix
components.
<div className='mt-1.5 flex flex-row justify-between'>
{Array.from({ length: max + 1 }).map((_, i) => (
<span
key={`${name}-${i}`}
className={clsx('text-sm font-light', { 'text-10 opacity-40': i > 0 && i < max })}
role='presentation'
>
{i === 0 || i === max ? i : '|'}
</span>
))}
</div>
The output looks like this
The 'stops' are pretty accurate to the step size without having to hardcode all those values, and this seems to work well so far. Happy to share more if anyone needs/has questions, but hopefully the snippet above is enough to get your slider going in the right direction.
Up
Good call on sharing David. Here's an implementation I did with react-hook-form
and react-range
styled with tailwind
. I opted to not move forward with radix-ui
for this.
Implementation:
import { Range, getTrackBackground } from 'react-range'
import { Label } from '@/components/atoms/Label'
import { Control, Controller, FieldValues } from 'react-hook-form'
export default function Slider<T extends FieldValues>({ min, max, control, disabled, isRequired, step, label, format, name, value }: SliderProps<T>) {
return (
<div className='flex flex-col'>
<div className='flex flex-col'>
<Label htmlFor='roleagefrom'>{label}</Label>
</div>
<div className='flex justify-center p-2'>
<Controller<T>
defaultValue={value}
rules={{ required: isRequired }}
control={control}
render={({ field: { value, onChange } }) => (
<div className='flex w-full flex-col'>
<Range
disabled={disabled}
values={value}
step={step}
min={min}
max={max}
rtl={false}
onChange={values => onChange(values as [number, number])}
renderMark={({ props }) => <div {...props} className={`h-4 w-1 rounded-full bg-[#ceff00]`} style={{ ...props.style }} />}
renderTrack={({ props, children }) => (
<div className='flex h-14 w-full' onMouseDown={props.onMouseDown} onTouchStart={props.onTouchStart} style={{ ...props.style }}>
<div
ref={props.ref}
className='h-1 w-full self-center rounded'
style={{
background: getTrackBackground({
values: value,
colors: ['#ccc', '#ceef90', '#ccc'],
min,
max,
rtl: false
})
}}
>
{children}
</div>
</div>
)}
renderThumb={({ index, props, isDragged }) => {
return (
<div
{...props}
style={{ ...props.style }}
// Thumbs
className='border-primary/50 focus-visible:ring-ring flex size-5 items-center justify-center rounded-full border bg-[#ceff00] shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50'
>
{/*Value labels. Only shows values on drag*/}
{isDragged && (
<div className='absolute rounded bg-black p-1 text-xs font-semibold text-gray-100' style={{ top: '-28px' }}>
{format ? format(value[index]) : value[index]}
</div>
)}
</div>
)
}}
/>
<div className='flex flex-col text-sm text-gray-400'>
From: {format ? format(value[0]) : value[0]} to {format ? format(value[1]) : value[1]}
</div>
</div>
)}
name={name}
/>
</div>
</div>
)
}
Types:
import type { FieldPathValue, RegisterOptions, FieldError, FieldPath, Path } from 'react-hook-form'
interface SliderProps<T extends FieldValues> extends Omit<InputProps<T>, 'registerOptions'> {
min: number
max: number
step: number
format?: (value: number) => string
control: Control<T>
}
export interface InputProps<T, U extends FieldPath<T> = Path<T>> {
// Form control name - not visible to user - the convoluted generic is to enable typing for this
name: U
// User visible label
label: string
// Triggers error message if defined
error?: FieldError<T>
// Makes this input mandatory
isRequired?: boolean
// CSS classes to pass through
className?: string
// Value to initialize the input with
value?: FieldPathValue<T, U>
// Validation and other options to pass to input registration
registerOptions?: RegisterOptions<T, U>
// Placeholder text
placeholder?: string
// Disables any changes to the input
disabled?: boolean
// Not implemented everywhere
loading?: boolean
}
Usage (example is lower and upper bounds for age)
<Slider step={2} min={0} max={70} label='Role Age' value={[18, 40]} control={control} name='age' isRequired error={errors.age} />
Definitely looking forward to this being part of the primitive itself, but for now I've rolled-in a pretty simple workaround to my project and figured I'd share:
I'm using
shadcn-ui
which usesradix
under the hood, and added this snippet underneath my local Slider component which is a wrapper around theshadcn-ui
andradix
components.<div className='mt-1.5 flex flex-row justify-between'> {Array.from({ length: max + 1 }).map((_, i) => ( <span key={`${name}-${i}`} className={clsx('text-sm font-light', { 'text-10 opacity-40': i > 0 && i < max })} role='presentation' > {i === 0 || i === max ? i : '|'} </span> ))} </div>
The output looks like this
The 'stops' are pretty accurate to the step size without having to hardcode all those values, and this seems to work well so far. Happy to share more if anyone needs/has questions, but hopefully the snippet above is enough to get your slider going in the right direction.
Could you please share a full example ?
I'm trying to create the following example and it's kinda tricky.
here is a working solution for any one that needs it @benoitgrelard maybe you can give it a look and update the slider
"use client";
import as React from "react"; import as SliderPrimitive from "@radix-ui/react-slider"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip";
interface SliderProps
extends React.ComponentPropsWithoutRef
const Slider = React.forwardRef<HTMLElement, SliderProps>( ({ className, showTooltip = false, ...props }, ref) => { const [value, setValue] = React.useState<number[]>( (props.defaultValue as number[]) ?? [0], ); const [showTooltipState, setShowTooltipState] = React.useState(false);
const handlePointerDown = () => {
setShowTooltipState(true);
};
const handlePointerUp = () => {
setShowTooltipState(false);
};
React.useEffect(() => {
document.addEventListener("pointerup", handlePointerUp);
return () => {
document.removeEventListener("pointerup", handlePointerUp);
};
}, []);
return (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
onValueChange={setValue}
onPointerDown={handlePointerDown}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<TooltipProvider>
<Tooltip open={showTooltip && showTooltipState}>
<TooltipTrigger asChild>
<SliderPrimitive.Thumb
className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
onMouseEnter={() => setShowTooltipState(true)}
onMouseLeave={() => setShowTooltipState(false)}
/>
</TooltipTrigger>
<TooltipContent>
<p>{value[0]}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SliderPrimitive.Root>
);
}, );
Slider.displayName = SliderPrimitive.Root.displayName as string;
export { Slider };
@Ghost-hk any chance you could post a screenshot / screencast with your code example? I'm intrigued by the tooltip usage, but would like to see how it behaves before jumping into implementing it locally.
@Ghost-hk looks awesome - thanks for sharing!
i am using shadcn, and here is my solution for the circular marks, it can be modified for custom use.
i extended the interface to look like:
interface SliderProps
extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
showTooltip?: boolean;
interval?: number;
}
this part is for calculating the position from left:
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
SliderProps
>(({ className, onValueChange, showTooltip = true, ...props }, ref) => {
const [value, setValue] = React.useState<number[]>(
(props.defaultValue as number[]) ?? (props.value as number[]) ?? [0]
);
const [innerInterval] = React.useState<number>(props.interval ?? props.step ?? 25);
const numberOfMarks = Math.floor(props.max ?? 100 / innerInterval) + 1;
const marks = Array.from({ length: numberOfMarks }, (_, i) => i * innerInterval);
function tickIndex(value: number): number {
// Calculate the index based on the value
return Math.floor(value / innerInterval);
}
function calculateTickPercent(index: number, max: number): number {
// Calculate the percentage from left of the slider's width
const percent = ((index * innerInterval) / max) * 100;
return percent;
}
function handleValueChange(v: number[]) {
setValue(v);
if (onValueChange) onValueChange(v);
}
and this part is for the tool tip by @Ghost-hk :
const [showTooltipState, setShowTooltipState] = React.useState(false);
const handlePointerDown = () => {
setShowTooltipState(true);
};
const handlePointerUp = () => {
setShowTooltipState(false);
};
React.useEffect(() => {
document.addEventListener("pointerup", handlePointerUp);
return () => {
document.removeEventListener("pointerup", handlePointerUp);
};
}, []);
and here is the component jsx:
return (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
onValueChange={handleValueChange}
onPointerDown={handlePointerDown}
{...props}>
<SliderPrimitive.Track className="relative h-1 w-full grow rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
{marks.map((_, i) => (
<Circle
id={`${i}`}
key={`${i}`}
role="presentation"
className={cn(
"text-sm h-2.5 w-2.5 -z-10 rounded-full absolute -top-1",
{
" text-secondary bg-secondary": i > tickIndex(value[0]!),
"text-primary bg-primary": i <= tickIndex(value[0]!),
}
)}
style={{
left: `${calculateTickPercent(i, props.max ?? 100)}%`,
translate: `-${calculateTickPercent(i, props.max ?? 100)}%`,
}}
strokeWidth="3px"
/>
))}
</SliderPrimitive.Track>
<TooltipProvider>
<Tooltip open={showTooltip && showTooltipState}>
<TooltipTrigger asChild>
<SliderPrimitive.Thumb
className="block h-2.5 w-2.5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50"
onMouseEnter={() => setShowTooltipState(true)}
onMouseLeave={() => setShowTooltipState(false)}
/>
</TooltipTrigger>
<TooltipContent className="w-auto p-2 mb-1">
<p className="font-medium">{value[0]}%</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SliderPrimitive.Root>
);
Hi @youssefwt, I tried your solution, and it's almost working as expected, but the marks are not showing, even with the svg's being there, as you can see in my code inspector below. Would you know what can be wrong?
I've already tried to remove tailwind parameter -z-10, change absolute to relative, increase the size, but keeps not appearing on screen.
Feature request
Provide a way to add points on the slider.
Overview
Examples in other libraries
https://mui.com/components/slider/#discrete-sliders
Who does this impact? Who is this for?
Additional context