radix-ui / primitives

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.
https://radix-ui.com/primitives
MIT License
15.35k stars 765 forks source link

[Slider] Add marks on a slider at specific points and allow only those to be selected #1188

Open raviteja83 opened 2 years ago

raviteja83 commented 2 years ago

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

benoitgrelard commented 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.

✌️

benoitgrelard commented 2 years ago

We probably won't do this anytime soon and would rather focus on producing more primitives. This is also probably possible in user-land.

jjenzz commented 2 years ago

@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.

benoitgrelard commented 2 years ago

That's a great point @jjenzz, this might need to be offered as part of the component for that reason then.

its-monotype commented 1 year ago

I hope marks will be added soon, I had a hard time making them myself

its-monotype commented 1 year ago

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

its-monotype commented 1 year ago

Any updates on that? 👀

ChungMasterFlex commented 1 year ago

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?

image

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.

benoitgrelard commented 1 year ago

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.

nickluger commented 1 year ago

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>
diehardsapp commented 10 months ago

Need one these man

frixaco commented 9 months ago

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... and set up a 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)

megetron commented 9 months ago

considering using this slider but missing this feature. is it possible in 2023?

liho00 commented 7 months ago

2024 need this feature

faizan-ali commented 7 months ago

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

Patolord commented 6 months ago

Up

lui7henrique commented 6 months ago

up

lovrozagar commented 6 months ago

Up

ranierimarques commented 6 months ago

Up

CreativeCreature7 commented 6 months ago

Up

lovrozagar commented 6 months ago

Up

darrel1925 commented 6 months ago

Up

mnbeh commented 6 months ago

Up

Zakisb commented 6 months ago

UPPPP !

Vladi-F commented 6 months ago

UUUPPPP!! 💯

ZackLyon commented 6 months ago

Up

lui7henrique commented 6 months ago

Up

prenansb commented 6 months ago

Up

abe1272001 commented 6 months ago

Up 🔝

maotora commented 5 months ago

Up 0_o

Zakisb commented 5 months ago

upppp!!!!

lui7henrique commented 5 months ago

upppppppppp 🆙🆙🆙🆙

nickluger commented 5 months ago

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.

willdavidow commented 5 months ago

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

image

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.

NaveenKumar7845 commented 4 months ago

Up

faizan-ali commented 4 months ago

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. image

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} />
minedun6 commented 4 months ago

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 image

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 ? Screenshot from 2024-05-04 19-13-52

I'm trying to create the following example and it's kinda tricky.

Ghost-hk commented 1 month ago

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 { showTooltip?: boolean; }

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 };

willdavidow commented 1 month ago

@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 commented 1 month ago

@willdavidow

https://github.com/user-attachments/assets/78c40beb-3627-42d7-a247-0427210b6ad1

willdavidow commented 1 month ago

@Ghost-hk looks awesome - thanks for sharing!

youssefwt commented 2 weeks ago

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>
    );

image

crtormen commented 2 weeks ago

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.

Screenshot_7