oeri

Slider

Usage

25%
50%
75%

onChange value: 35onChangeEnd value: 35
"use client";
import { useState } from "react";
import { Slider } from "@/ui/slider";
import { Svg } from "@/ui/svg";
import { Typography } from "@/ui/typography";
import { useApp as useAppContext } from "@/config/app-context";

const INITIAL_VALUES: number = 35;
const marks = [
  { value: 41, label: "25%" },
  { value: 66, label: "50%" },
  { value: 91, label: "75%" }
];

export function SliderDemo() {
  const ctx = useAppContext();
  const [value, setValue] = useState<number>(INITIAL_VALUES);
  const [endValue, setEndValue] = useState<number>(INITIAL_VALUES);

  return (
    <div className="flex size-full max-w-96 flex-col items-center justify-center [&_svg]:mr-auto [&_svg]:rtl:ml-auto [&_svg]:rtl:mr-0">
      <Slider dir={ctx.dir} value={value} onChange={setValue} onChangeEnd={setEndValue} marks={marks} min={16} max={116} />

      <hr className="mt-14 w-full border-t border-border" />

      <Typography prose="span">
        onChange value: <b>{value}</b>
      </Typography>
      <Svg size={value} currentFill="fill" role="img" fill="#149eca">
        <circle cx="12" cy="12" r="2.1" />
        <path d="m19.62,8.19c-.19-.09-.39-.17-.59-.25.03-.22.06-.43.08-.64.24-2.38-.33-4.09-1.61-4.83-1.28-.74-3.05-.38-4.99,1.02-.17.12-.34.25-.51.39-.17-.14-.34-.27-.51-.39-1.94-1.4-3.71-1.76-4.99-1.02-1.28.74-1.85,2.45-1.61,4.83.02.21.05.42.08.64-.2.08-.4.16-.59.25-2.18.98-3.38,2.33-3.38,3.81s1.2,2.83,3.38,3.81c.19.09.39.17.59.25-.03.22-.06.43-.08.64-.24,2.38.33,4.09,1.61,4.83.43.25.92.37,1.46.37,1.04,0,2.25-.47,3.53-1.39.17-.12.34-.25.51-.39.17.14.34.27.51.39,1.28.92,2.49,1.39,3.53,1.39.53,0,1.02-.12,1.46-.37,1.28-.74,1.85-2.45,1.61-4.83-.02-.21-.05-.42-.08-.64.2-.08.4-.16.59-.25,2.18-.98,3.38-2.33,3.38-3.81s-1.2-2.83-3.38-3.81Zm-6.5-3.85c1.09-.79,2.1-1.19,2.92-1.19.35,0,.67.08.94.23.89.51,1.29,1.9,1.09,3.82-.01.13-.03.26-.05.39-.96-.29-2-.51-3.1-.65-.67-.88-1.38-1.68-2.11-2.36.11-.08.21-.16.32-.24Zm-6.36,8.98c.22.43.45.87.7,1.3.25.43.51.85.78,1.25-.73-.13-1.43-.3-2.07-.5.15-.66.35-1.35.6-2.05Zm-.6-4.69c.65-.2,1.34-.37,2.07-.5-.27.4-.53.82-.78,1.25-.25.43-.48.86-.7,1.3-.25-.7-.46-1.38-.6-2.05Zm1.14,3.37c.31-.7.67-1.4,1.07-2.1.4-.69.83-1.36,1.28-1.97.76-.08,1.55-.12,2.35-.12s1.59.04,2.35.12c.45.62.88,1.28,1.28,1.97.4.69.76,1.4,1.07,2.1-.31.7-.67,1.4-1.07,2.1-.4.69-.83,1.36-1.28,1.97-.76.08-1.55.12-2.35.12s-1.59-.04-2.35-.12c-.45-.62-.88-1.28-1.28-1.97-.4-.69-.76-1.4-1.07-2.1Zm9.24,2.62c.25-.43.48-.86.7-1.3.25.7.46,1.38.6,2.05-.65.2-1.34.37-2.07.5.27-.4.53-.82.78-1.25Zm.7-3.94c-.22-.43-.45-.87-.7-1.3-.25-.43-.51-.85-.78-1.25.73.13,1.43.3,2.07.5-.15.66-.35,1.35-.6,2.05Zm-5.23-5.42c.5.46.99.98,1.47,1.55-.48-.03-.98-.05-1.47-.05s-.99.02-1.47.05c.48-.57.97-1.09,1.47-1.55Zm-4.98-1.88c.27-.15.58-.23.94-.23.81,0,1.83.41,2.92,1.19.11.08.21.16.32.24-.73.68-1.44,1.48-2.11,2.36-1.1.14-2.15.36-3.1.65-.02-.13-.03-.26-.05-.39-.19-1.91.21-3.3,1.09-3.82Zm-2.22,11.47c-1.75-.79-2.76-1.83-2.76-2.86s1.01-2.07,2.76-2.86c.12-.05.24-.11.37-.16.22.97.56,1.99.99,3.01-.43,1.02-.76,2.04-.99,3.01-.12-.05-.25-.1-.37-.16Zm6.07,4.8c-1.56,1.12-2.96,1.47-3.85.96-.89-.51-1.29-1.9-1.09-3.82.01-.13.03-.26.05-.39.96.29,2,.51,3.1.65.67.88,1.38,1.68,2.11,2.36-.11.08-.21.16-.32.24Zm1.12-.92c-.5-.46-.99-.98-1.47-1.55.48.03.98.05,1.47.05s.99-.02,1.47-.05c-.48.57-.97,1.09-1.47,1.55Zm4.98,1.88c-.89.51-2.29.16-3.85-.96-.11-.08-.21-.16-.32-.24.73-.68,1.44-1.48,2.11-2.36,1.1-.14,2.15-.36,3.1-.65.02.13.03.26.05.39.19,1.91-.21,3.3-1.09,3.82Zm2.22-5.76c-.12.05-.24.11-.37.16-.22-.97-.56-1.99-.99-3.01.43-1.02.76-2.04.99-3.01.12.05.25.1.37.16,1.75.79,2.76,1.83,2.76,2.86s-1.01,2.07-2.76,2.86Z" />
      </Svg>

      <Typography prose="span">
        onChangeEnd value: <b>{endValue}</b>
      </Typography>
      <Svg size={endValue} currentFill="fill" role="img">
        <path d="m11.61,1c-.16,0-.28,0-.33,0-.05,0-.2.02-.33.03-3.12.28-6.05,1.97-7.91,4.56-1.03,1.44-1.69,3.07-1.94,4.81-.09.6-.1.78-.1,1.6s.01,1,.1,1.6c.6,4.13,3.54,7.6,7.52,8.89.71.23,1.47.39,2.32.48.33.04,1.77.04,2.11,0,1.48-.16,2.73-.53,3.96-1.16.19-.1.23-.12.2-.14-.02-.01-.82-1.09-1.79-2.4l-1.76-2.38-2.2-3.26c-1.21-1.79-2.21-3.26-2.22-3.26,0,0-.02,1.45-.02,3.22,0,3.1,0,3.22-.05,3.3-.06.11-.1.15-.19.2-.07.03-.13.04-.45.04h-.37l-.1-.06c-.06-.04-.11-.09-.14-.16l-.05-.1v-4.31s.01-4.31.01-4.31l.07-.08s.11-.1.16-.13c.09-.04.12-.05.49-.05.44,0,.51.02.63.14.03.03,1.23,1.83,2.65,4,1.45,2.19,2.89,4.38,4.34,6.57l1.74,2.64.09-.06c.78-.51,1.61-1.23,2.26-1.98,1.39-1.6,2.29-3.55,2.59-5.62.09-.6.1-.78.1-1.6s-.01-1-.1-1.6c-.6-4.13-3.54-7.6-7.52-8.89-.7-.23-1.45-.38-2.29-.48-.15-.02-.99-.03-1.48-.03h0Zm3.73,6.62c.32,0,.37,0,.45.04.1.05.19.15.22.25.02.06.02,1.25.02,3.95v3.87s-.69-1.05-.69-1.05l-.68-1.05v-2.81c0-1.82,0-2.84.02-2.89.03-.12.11-.22.21-.27.09-.05.12-.05.46-.05h0Z" />
      </Svg>
    </div>
  );
}

Properties

25%
50%
75%
onChange value: 35onChangeEnd value: 35
Color
Size
Round

Slider Inverted

import { RangeSlider, Slider } from '@/ui/slider';

export function SliderInvertedDemo() {
  return (
    <div className="flex size-full max-w-96 flex-col items-center justify-center gap-10">
      <Slider inverted defaultValue={80} />
      <RangeSlider inverted defaultValue={[40, 80]} />
    </div>
  );
}

Slider Label

No label
Formatted label
Label always visible
40
Custom label transition
"use client";
import React from "react";
import { Slider } from "@/ui/slider";
import { useApp as useAppContext } from "@/config/app-context";
import { Typography } from "@/ui/typography";

export function SliderLabelDemo() {
  const ctx = useAppContext();
  return (
    <div className="mb-12 flex size-full max-w-96 flex-col items-center justify-center [&>span:not(:first-of-type)]:mt-6">
      <Typography prose="span">No label</Typography>
      <Slider dir={ctx.dir} defaultValue={40} label={null} />

      <Typography prose="span">Formatted label</Typography>
      <Slider dir={ctx.dir} defaultValue={40} label={(value) => `${value} °C`} />

      <Typography prose="span">Label always visible</Typography>
      <Slider dir={ctx.dir} defaultValue={40} labelAlwaysOn />

      <Typography prose="span">Custom label transition</Typography>
      <Slider dir={ctx.dir} defaultValue={40} labelTransitionProps={{ transition: "skew-down", duration: 150, timingFunction: "linear" }} />
    </div>
  );
}

Slider Marks

Marks
xs
sm
md
lg
xl
20%
50%
80%
Restrict selection to marks
Disabled
xs
sm
md
lg
xl
import { Slider, RangeSlider } from '@/ui/slider';
import { Typography } from "@/ui/typography";

const marks = [
  { value: 0, label: "xs" },
  { value: 25, label: "sm" },
  { value: 50, label: "md" },
  { value: 75, label: "lg" },
  { value: 100, label: "xl" }
];

export function SliderMarksDemo() {
  return (
    <div className="m-auto mb-12 flex size-full flex-col items-center justify-center [&>*]:max-w-96">
      <Typography prose="span">Marks</Typography>
      <Slider defaultValue={40} marks={[{ value: 10 }, { value: 40 }, { value: 95 }]} />
      <Slider defaultValue={40} marks={marks} className="mt-6" />
      <RangeSlider defaultValue={[20, 80]} marks={[{ value: 20, label: "20%" }, { value: 50, label: "50%" }, { value: 80, label: "80%" }]} className="mt-6" />

      <Typography prose="span" className="mt-10">Restrict selection to marks</Typography>
      <Slider restrictToMarks defaultValue={25} marks={Array.from({ length: 5 }).map((_, index) => ({ value: index * 25 }))} />

      <Typography prose="span" className="mt-6">Disabled</Typography>
      <Slider defaultValue={60} disabled />
      <RangeSlider disabled defaultValue={[25, 75]} marks={marks} className="mt-6" />
    </div>
  );
}

Slider Scale

1 MB
1 MB
1 GB
import { RangeSlider, Slider } from '@/ui/slider';

const getScale = (v: number) => 2 ** v;
function valueLabelFormat(value: number) {
  const units = ["KB", "MB", "GB", "TB"];
  let unitIndex = 0;
  let scaledValue = value;
  while (scaledValue >= 1024 && unitIndex < units.length - 1) {
    unitIndex += 1;
    scaledValue /= 1024;
  }
  return `${scaledValue} ${units[unitIndex]}`;
}

export function SliderScaleDemo() {
  return (
    <div className="flex size-full max-w-96 flex-col items-center justify-center gap-12 py-8">
      <Slider scale={getScale} step={1} min={2} max={30} labelAlwaysOn defaultValue={10} label={valueLabelFormat} />

      <RangeSlider scale={getScale} step={1} min={2} max={30} labelAlwaysOn defaultValue={[10, 20]} label={valueLabelFormat} />
    </div>
  );
}

Slider Step

Decimal Values

Decimal step
Step matched with marks
xs
sm
md
lg
xl
import { Slider, RangeSlider } from '@/ui/slider';
import { Typography } from "@/ui/typography";

const marks = [
  { value: 0, label: "xs" },
  { value: 25, label: "sm" },
  { value: 50, label: "md" },
  { value: 75, label: "lg" },
  { value: 100, label: "xl" }
];

export function SliderStepDemo() {
  return (
    <div className="mb-12 flex size-full max-w-96 flex-col items-center justify-center">
      <Typography prose="span">Decimal Values</Typography>
      <Slider min={0} max={1} step={0.0005} defaultValue={0.5535} />
      <RangeSlider minRange={0.2} min={0} max={1} step={0.0005} defaultValue={[0.1245, 0.5535]} />

      <Typography prose="span" className="mt-6">Decimal step</Typography>
      <Slider defaultValue={0} min={-10} max={10} label={(value) => value.toFixed(1)} step={0.1} />

      <Typography prose="span" className="mt-6">Step matched with marks</Typography>
      <Slider
        defaultValue={50}
        label={(val) => marks.find((mark) => mark.value === val)!.label}
        step={25}
        marks={marks}
      />
    </div>
  );
}

Slider Thumb

Thumb Icon
Thumb size

API References

Styles API

type T = "root" | "label" | "thumb" | "trackContainer" | "track" | "bar" | "markWrapper" | "mark" | "markLabel";
Styles APITypeDefaultAnnotation
unstyled?Partial<Record<T, boolean>>falseif true, default styles will be removed
className?stringundefinedpass to root component <div>
classNames?Partial<Record<T, string>>undefined
style?CSSPropertiesundefinedpass to root component <div>
styles?Partial<Record<T, CSSProperties>>undefined

Props API

Props APITypeDefaultAnnotation
dir?"ltr" | "rtl"ltrType of direction slider
color?CSSProperties["color"]hsl(var(--constructive))Key of valid CSS color, controls color of track and thumb
round?number | string999Key of valid CSS value to set border-radius, numbers are converted to rem
size?(string & {}) | number12Controls size of the track
min?number0Minimal possible value
max?number100Maximum possible value
step?number1Number by which value will be incremented/decremented with thumb drag and arrows
precision?numberNumber of significant digits after the decimal point
value?numberControlled component value
defaultValue?numberUncontrolled component default value
onChange?(value: number) => voidCalled when value changes
onChangeEnd?(value: number) => voidCalled when user stops dragging slider or changes value with arrows
name?stringHidden input name, use with uncontrolled component
marks?{ value: number; label?: React.ReactNode }[]Marks displayed on the track
label?React.ReactNode | ((value: number) => React.ReactNode)Function to generate label or any react node to render instead, set to null to disable label
labelTransitionProps?TransitionOverride0Props passed down to the Transition component, { transition: 'fade', duration: 0 }
labelAlwaysOn?booleanfalseDetermines whether the label should be visible when the slider is not being dragged or hovered
thumbLabel?stringThumb aria-label
showLabelOnHover?booleantrueDetermines whether the label should be displayed when the slider is hovered
thumbChildren?React.ReactNodeContent rendered inside thumb
disabled?booleanfalseDisables slider
thumbSize?number | stringsizeThumb width and height, by default value is computed based on size prop
scale?(value: number) => numberA transformation function to change the scale of the slider
inverted?booleanfalseDetermines whether track value representation should be inverted
hiddenInputProps?React.ComponentPropsWithoutRef<"input">Props passed down to the hidden input
restrictToMarks?booleanfalseDetermines whether the selection should be only allowed from the given marks array
thumbProps?React.ComponentPropsWithoutRef<"div">Props passed down to thumb element

Source Codes

slider.tsx
"use client";
import * as React from "react";
import { clamp, useMove } from "@/hooks/use-move";
import { Transition, TransitionOverride } from "@/hooks/use-dialog";
import { useUncontrolled } from "@/hooks/use-uncontrolled";
import { cn, cvx, rem, type inferType, type cvxProps } from "cretex";
import { useMergedRef } from "@/hooks/use-merged-ref";

const classes = cvx({
  variants: {
    selector: {
      root: "stylelayer-slider",
      label: "slider-label",
      thumb: "slider-thumb",
      trackContainer: "slider-track-container",
      track: "slider-track",
      bar: "slider-bar",
      markWrapper: "slider-mark-wrapper",
      mark: "slider-mark",
      markLabel: "slider-mark-label"
    }
  }
});

type __Selector = NonNullable<cvxProps<typeof classes>["selector"]>;
type Options = StylesNames<__Selector> &
  __SliderProps & {
    // variant?: string;
  };
type CSSProperties = React.CSSProperties & { [key: string]: any };
type NestedRecord<U extends [string, unknown], T extends string> = {
  [K in U as K[0]]?: Partial<Record<T, K[1]>>;
};
type Styles = ["unstyled", boolean] | ["classNames", string] | ["styles", CSSProperties];
type StylesNames<T extends string, Exclude extends string = never> = Omit<NestedRecord<Styles, T> & { className?: string; style?: CSSProperties }, Exclude>;
type ComponentProps<T extends React.ElementType, Exclude extends string = never> = StylesNames<__Selector> &
  React.PropsWithoutRef<Omit<React.ComponentProps<T>, "style" | "color" | Exclude>> & { color?: CSSProperties["color"] };

type CtxProps = {
  dir: "ltr" | "rtl";
  getStyles(selector: __Selector, options?: Options): inferType<typeof getStyles>;
};

function getStyles(selector: __Selector, options: Options = {}) {
  const { size = 8, color = "hsl(var(--constructive))", thumbSize, round = 999, unstyled, className, classNames, style, styles, disabled, inverted } = options;
  return {
    "aria-disabled": disabled ? "true" : undefined,
    "data-disabled": disabled ? "true" : undefined,
    "data-inverted": inverted ? "true" : undefined,
    className: cn(!unstyled?.[selector] && classes({ selector }), classNames?.[selector], className),
    style: {
      ...(selector === "root"
        ? {
            "--slider-size": rem(size),
            "--slider-label-fz": "0.8125rem",
            "--slider-color": color,
            "--slider-thumb-size": thumbSize !== undefined ? rem(thumbSize) : "calc(var(--slider-size) * 2)",
            "--slider-round": rem(round),
            "--slider-track-bg": "hsl(var(--muted-foreground))",
            "--slider-track-disabled-bg": "hsl(var(--muted))"
          }
        : undefined),
      pointerEvents: disabled ? "none" : undefined,
      ...styles?.[selector],
      ...style
    } as CSSProperties
  };
}

const ctx = React.createContext<CtxProps | undefined>(undefined);
const useSlider = () => React.useContext(ctx)!;

export interface __SliderProps {
  inverted?: boolean;
  disabled?: boolean;
  size?: (string & {}) | number;
  round?: (string & {}) | number;
  thumbSize?: string | number;
  color?: CSSProperties["color"];
}

export interface SliderProps extends __SliderProps, ComponentProps<"div", "value" | "onChange"> {
  min?: number;
  max?: number;
  step?: number;
  precision?: number;
  name?: string;
  marks?: { value: number; label?: React.ReactNode }[];
  label?: React.ReactNode | ((value: number) => React.ReactNode);
  labelTransitionProps?: TransitionOverride;
  labelAlwaysOn?: boolean;
  thumbLabel?: string;
  showLabelOnHover?: boolean;
  thumbChildren?: React.ReactNode;
  hiddenInputProps?: React.ComponentPropsWithoutRef<"input">;
  restrictToMarks?: boolean;
  thumbProps?: React.ComponentPropsWithoutRef<"div">;
  dir?: "ltr" | "rtl";
  value?: number;
  defaultValue?: number;
  onChange?: (value: number) => void;
  scale?: (value: number) => number;
  onChangeEnd?: (value: number) => void;
}

export const Slider = React.forwardRef<HTMLDivElement, SliderProps>((_props, ref) => {
  const {
    classNames,
    styles,
    value,
    onChange,
    onChangeEnd,
    size,
    defaultValue,
    name,
    thumbChildren,
    unstyled,
    inverted,
    hiddenInputProps,
    restrictToMarks,
    thumbProps,
    round,
    min = 0,
    max = 100,
    step = 1,
    marks = [],
    label = f => f,
    precision: _precision,
    dir = "ltr",
    labelTransitionProps = { transition: "fade", duration: 0 },
    labelAlwaysOn = false,
    thumbLabel = "",
    showLabelOnHover = true,
    disabled = false,
    scale = v => v,
    ...props
  } = _props;

  const [hovered, setHovered] = React.useState(false);
  const [_value, setValue] = useUncontrolled({
    value: typeof value === "number" ? clamp(value, min!, max!) : value,
    defaultValue: typeof defaultValue === "number" ? clamp(defaultValue, min!, max!) : defaultValue,
    finalValue: clamp(0, min!, max!),
    onChange
  });

  const valueRef = React.useRef(_value);
  const root = React.useRef<HTMLDivElement>(null);
  const thumb = React.useRef<HTMLDivElement>(null);
  const position = getPosition({ value: _value, min: min!, max: max! });
  const scaledValue = scale!(_value);
  const _label = typeof label === "function" ? label(scaledValue) : label;
  const precision = _precision ?? getPrecision(step!);

  const handleChange = React.useCallback(
    ({ x }: { x: number }) => {
      if (!disabled) {
        const nextValue = getChangeValue({
          value: x,
          min: min!,
          max: max!,
          step: step!,
          precision
        });
        setValue(
          restrictToMarks && marks?.length
            ? findClosestNumber(
                nextValue,
                marks.map(mark => mark.value)
              )
            : nextValue
        );
        valueRef.current = nextValue;
      }
    },
    [disabled, min, max, step, precision, setValue, marks, restrictToMarks]
  );

  const { ref: container, active } = useMove(
    handleChange,
    {
      onScrubEnd: () =>
        onChangeEnd?.(
          restrictToMarks && marks?.length
            ? findClosestNumber(
                valueRef.current,
                marks.map(mark => mark.value)
              )
            : valueRef.current
        )
    },
    dir
  );

  const handleTrackKeydownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (!disabled) {
      switch (event.key) {
        case "ArrowUp": {
          event.preventDefault();
          thumb.current?.focus();
          if (restrictToMarks && marks) {
            const nextValue = getNextMarkValue(_value, marks);
            setValue(nextValue);
            onChangeEnd?.(nextValue);
            break;
          }
          const nextValue = getFloatingValue(Math.min(Math.max(_value + step!, min!), max!), precision);
          setValue(nextValue);
          onChangeEnd?.(nextValue);
          break;
        }
        case "ArrowRight": {
          event.preventDefault();
          thumb.current?.focus();
          if (restrictToMarks && marks) {
            const nextValue = dir === "rtl" ? getPreviousMarkValue(_value, marks) : getNextMarkValue(_value, marks);
            setValue(nextValue);
            onChangeEnd?.(nextValue);
            break;
          }
          const nextValue = getFloatingValue(Math.min(Math.max(dir === "rtl" ? _value - step! : _value + step!, min!), max!), precision);
          setValue(nextValue);
          onChangeEnd?.(nextValue);
          break;
        }
        case "ArrowDown": {
          event.preventDefault();
          thumb.current?.focus();

          if (restrictToMarks && marks) {
            const nextValue = getPreviousMarkValue(_value, marks);
            setValue(nextValue);
            onChangeEnd?.(nextValue);
            break;
          }
          const nextValue = getFloatingValue(Math.min(Math.max(_value - step!, min!), max!), precision);
          setValue(nextValue);
          onChangeEnd?.(nextValue);
          break;
        }
        case "ArrowLeft": {
          event.preventDefault();
          thumb.current?.focus();
          if (restrictToMarks && marks) {
            const nextValue = dir === "rtl" ? getNextMarkValue(_value, marks) : getPreviousMarkValue(_value, marks);
            setValue(nextValue);
            onChangeEnd?.(nextValue);
            break;
          }
          const nextValue = getFloatingValue(Math.min(Math.max(dir === "rtl" ? _value + step! : _value - step!, min!), max!), precision);
          setValue(nextValue);
          onChangeEnd?.(nextValue);
          break;
        }

        case "Home": {
          event.preventDefault();
          thumb.current?.focus();
          if (restrictToMarks && marks) {
            setValue(getFirstMarkValue(marks));
            onChangeEnd?.(getFirstMarkValue(marks));
            break;
          }
          setValue(min!);
          onChangeEnd?.(min!);
          break;
        }
        case "End": {
          event.preventDefault();
          thumb.current?.focus();
          if (restrictToMarks && marks) {
            setValue(getLastMarkValue(marks));
            onChangeEnd?.(getLastMarkValue(marks));
            break;
          }
          setValue(max!);
          onChangeEnd?.(max!);
          break;
        }

        default: {
          break;
        }
      }
    }
  };

  const stylesApi = { unstyled, classNames, styles, disabled };

  return (
    <ctx.Provider value={{ dir, getStyles }}>
      <Edge
        key={dir}
        {...{
          el: "div",
          selector: "root",
          ref: useMergedRef(ref, root),
          onKeyDownCapture: handleTrackKeydownCapture,
          onMouseDownCapture: () => root.current?.focus(),
          dir,
          size,
          round,
          ...stylesApi,
          ...props
        }}
      >
        <SliderTrack
          {...{
            inverted,
            offset: 0,
            position,
            marks,
            min,
            max,
            value: scaledValue,
            containerProps: {
              ref: container as any,
              onMouseEnter: showLabelOnHover ? () => setHovered(true) : undefined,
              onMouseLeave: showLabelOnHover ? () => setHovered(false) : undefined
            },
            ...stylesApi
          }}
        >
          <SliderThumb
            {...{
              max,
              min,
              value: scaledValue,
              position,
              dragging: active,
              label: _label,
              ref: thumb as any,
              labelTransitionProps,
              labelAlwaysOn,
              thumbLabel,
              showLabelOnHover,
              isHovered: hovered,
              ...stylesApi,
              ...thumbProps
            }}
          >
            {thumbChildren}
          </SliderThumb>
        </SliderTrack>

        <input type="hidden" id={name} name={name} value={scaledValue} {...hiddenInputProps} />
      </Edge>
    </ctx.Provider>
  );
}) as SliderComponent;
Slider.displayName = "Slider";

export type RangeSliderValue = [number, number];
export interface RangeSliderProps extends ComponentProps<"div", "onChange" | "value" | "defaultValue"> {
  color?: CSSProperties["color"];
  round?: (string & {}) | number;
  size?: (string & {}) | number;
  min?: number;
  max?: number;
  step?: number;
  precision?: number;
  value?: RangeSliderValue;
  defaultValue?: RangeSliderValue;
  onChange?: (value: RangeSliderValue) => void;
  onChangeEnd?: (value: RangeSliderValue) => void;
  name?: string;
  marks?: { value: number; label?: React.ReactNode }[];
  label?: React.ReactNode | ((value: number) => React.ReactNode);
  labelTransitionProps?: TransitionOverride;
  labelAlwaysOn?: boolean;
  showLabelOnHover?: boolean;
  thumbChildren?: React.ReactNode;
  disabled?: boolean;
  thumbSize?: number | string;
  scale?: (value: number) => number;
  inverted?: boolean;
  minRange?: number;
  maxRange?: number;
  thumbFromLabel?: string;
  thumbToLabel?: string;
  hiddenInputProps?: React.ComponentPropsWithoutRef<"input">;
  thumbProps?: (index: 0 | 1) => React.ComponentPropsWithoutRef<"div">;
  dir?: "ltr" | "rtl";
}
export const RangeSlider = React.forwardRef<HTMLDivElement, RangeSliderProps>((_props, ref) => {
  const {
    classNames,
    styles,
    value,
    onChange,
    onChangeEnd,
    size,
    maxRange,
    precision: _precision,
    defaultValue,
    name,
    thumbFromLabel,
    thumbToLabel,
    thumbChildren,
    unstyled,
    inverted,
    dir = "ltr",
    hiddenInputProps,
    thumbProps,
    min = 0,
    max = 100,
    minRange = 10,
    step = 1,
    marks = [],
    label = f => f,
    labelTransitionProps = { transition: "fade", duration: 0 },
    labelAlwaysOn = false,
    showLabelOnHover = true,
    disabled = false,
    scale = v => v,
    round,
    ...props
  } = _props;
  const [focused, setFocused] = React.useState(-1);
  const [hovered, setHovered] = React.useState(false);
  const [_value, setValue] = useUncontrolled<RangeSliderValue>({
    value,
    defaultValue,
    finalValue: [min!, max!],
    onChange
  });
  const valueRef = React.useRef(_value);
  const thumbs = React.useRef<HTMLDivElement[]>([]);
  const thumbIndex = React.useRef<number | undefined>(undefined);
  const positions = [getPosition({ value: _value[0], min: min!, max: max! }), getPosition({ value: _value[1], min: min!, max: max! })];

  const precision = _precision ?? getPrecision(step!);

  const _setValue = (val: RangeSliderValue) => {
    setValue(val);
    valueRef.current = val;
  };

  React.useEffect(
    () => {
      if (Array.isArray(value)) {
        valueRef.current = value;
      }
    },
    Array.isArray(value) ? [value[0], value[1]] : [null, null]
  );

  const setRangedValue = (val: number, index: number, triggerChangeEnd: boolean) => {
    const clone: RangeSliderValue = [...valueRef.current];
    clone[index] = val;

    if (index === 0) {
      if (val > clone[1] - (minRange! - 0.000000001)) {
        clone[1] = Math.min(val + minRange!, max!);
      }
      if (val > (max! - (minRange! - 0.000000001) || min!)) {
        clone[index] = valueRef.current[index];
      }
      if (clone[1] - val > maxRange!) {
        clone[1] = val + maxRange!;
      }
    }

    if (index === 1) {
      if (val < clone[0] + minRange!) {
        clone[0] = Math.max(val - minRange!, min!);
      }
      if (val < clone[0] + minRange!) {
        clone[index] = valueRef.current[index];
      }
      if (val - clone[0] > maxRange!) {
        clone[0] = val - maxRange!;
      }
    }
    clone[0] = getFloatingValue(clone[0], precision);
    clone[1] = getFloatingValue(clone[1], precision);
    _setValue(clone);
    if (triggerChangeEnd) {
      onChangeEnd?.(valueRef.current);
    }
  };

  const handleChange = (val: number) => {
    if (!disabled) {
      const nextValue = getChangeValue({
        value: val,
        min: min!,
        max: max!,
        step: step!,
        precision
      });
      setRangedValue(nextValue, thumbIndex.current!, false);
    }
  };

  const { ref: container, active } = useMove(({ x }) => handleChange(x), { onScrubEnd: () => onChangeEnd?.(valueRef.current) }, dir);

  function handleThumbMouseDown(index: number) {
    thumbIndex.current = index;
  }

  const handleTrackMouseDownCapture = (event: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
    container.current!.focus();
    const rect = container.current!.getBoundingClientRect();
    const changePosition = getClientPosition(event.nativeEvent);
    const changeValue = getChangeValue({
      value: changePosition - rect.left,
      max: max!,
      min: min!,
      step: step!,
      containerWidth: rect.width
    });
    const nearestHandle = Math.abs(_value[0] - changeValue) > Math.abs(_value[1] - changeValue) ? 1 : 0;
    const _nearestHandle = dir === "ltr" ? nearestHandle : nearestHandle === 1 ? 0 : 1;
    thumbIndex.current = _nearestHandle;
  };

  const getFocusedThumbIndex = () => {
    if (focused !== 1 && focused !== 0) {
      setFocused(0);
      return 0;
    }
    return focused;
  };

  const handleTrackKeydownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (!disabled) {
      switch (event.key) {
        case "ArrowUp": {
          event.preventDefault();
          const focusedIndex = getFocusedThumbIndex();
          thumbs.current[focusedIndex].focus();
          setRangedValue(getFloatingValue(Math.min(Math.max(valueRef.current[focusedIndex] + step!, min!), max!), precision), focusedIndex, true);
          break;
        }
        case "ArrowRight": {
          event.preventDefault();
          const focusedIndex = getFocusedThumbIndex();
          thumbs.current[focusedIndex].focus();
          setRangedValue(
            getFloatingValue(
              Math.min(Math.max(dir === "rtl" ? valueRef.current[focusedIndex] - step! : valueRef.current[focusedIndex] + step!, min!), max!),
              precision
            ),
            focusedIndex,
            true
          );
          break;
        }
        case "ArrowDown": {
          event.preventDefault();
          const focusedIndex = getFocusedThumbIndex();
          thumbs.current[focusedIndex].focus();
          setRangedValue(getFloatingValue(Math.min(Math.max(valueRef.current[focusedIndex] - step!, min!), max!), precision), focusedIndex, true);
          break;
        }
        case "ArrowLeft": {
          event.preventDefault();
          const focusedIndex = getFocusedThumbIndex();
          thumbs.current[focusedIndex].focus();
          setRangedValue(
            getFloatingValue(
              Math.min(Math.max(dir === "rtl" ? valueRef.current[focusedIndex] + step! : valueRef.current[focusedIndex] - step!, min!), max!),
              precision
            ),
            focusedIndex,
            true
          );
          break;
        }
        default: {
          break;
        }
      }
    }
  };

  const sharedThumbProps = {
    max: max!,
    min: min!,
    size,
    labelTransitionProps,
    labelAlwaysOn,
    onBlur: () => setFocused(-1)
  };

  const hasArrayThumbChildren = Array.isArray(thumbChildren);
  const stylesApi = { unstyled, classNames, styles, disabled };

  return (
    <ctx.Provider value={{ dir, getStyles }}>
      <Edge key={dir} {...{ el: "div", selector: "root", ref, dir, size, round, ...stylesApi, ...props }}>
        <SliderTrack
          {...{
            offset: positions[0],
            marksOffset: _value[0],
            position: positions[1] - positions[0],
            marks,
            inverted,
            min,
            max,
            value: _value[1],
            containerProps: {
              ref: container as any,
              onMouseEnter: showLabelOnHover ? () => setHovered(true) : undefined,
              onMouseLeave: showLabelOnHover ? () => setHovered(false) : undefined,
              onTouchStartCapture: handleTrackMouseDownCapture,
              onTouchEndCapture: () => {
                thumbIndex.current = -1;
              },
              onMouseDownCapture: handleTrackMouseDownCapture,
              onMouseUpCapture: () => {
                thumbIndex.current = -1;
              },
              onKeyDownCapture: handleTrackKeydownCapture
            },
            ...stylesApi
          }}
        >
          <SliderThumb
            {...{
              value: scale!(_value[0]),
              position: positions[0],
              dragging: active,
              label: typeof label === "function" ? label(getFloatingValue(scale!(_value[0]), precision)) : label,
              ref: node => {
                thumbs.current[0] = node!;
              },
              thumbLabel: thumbFromLabel,
              onMouseDown: () => handleThumbMouseDown(0),
              onFocus: () => setFocused(0),
              showLabelOnHover,
              isHovered: hovered,
              ...thumbProps?.(0),
              ...sharedThumbProps,
              ...stylesApi,
              ...thumbProps
            }}
          >
            {hasArrayThumbChildren ? thumbChildren[0] : thumbChildren}
          </SliderThumb>

          <SliderThumb
            {...{
              thumbLabel: thumbToLabel,
              value: scale!(_value[1]),
              position: positions[1],
              dragging: active,
              label: typeof label === "function" ? label(getFloatingValue(scale!(_value[1]), precision)) : label,
              ref: node => {
                thumbs.current[1] = node!;
              },
              onMouseDown: () => handleThumbMouseDown(1),
              onFocus: () => setFocused(1),
              showLabelOnHover,
              isHovered: hovered,
              ...thumbProps?.(1),
              ...sharedThumbProps,
              ...stylesApi,
              ...thumbProps
            }}
          >
            {hasArrayThumbChildren ? thumbChildren[1] : thumbChildren}
          </SliderThumb>
        </SliderTrack>

        <input type="hidden" name={`${name}_from`} value={_value[0]} {...hiddenInputProps} />
        <input type="hidden" name={`${name}_to`} value={_value[1]} {...hiddenInputProps} />
      </Edge>
    </ctx.Provider>
  );
});
RangeSlider.displayName = "RangeSlider";

export interface TrackProps extends ComponentProps<"div"> {
  position: number;
  offset?: number;
  marksOffset?: number;
  marks: { value: number; label?: React.ReactNode }[] | undefined;
  min: number;
  max: number;
  value: number;
  children: React.ReactNode;
  containerProps?: React.PropsWithRef<React.ComponentProps<"div">>;
  disabled: boolean | undefined;
  inverted: boolean | undefined;
}
export const SliderTrack = React.forwardRef<HTMLDivElement, TrackProps>((_props, ref) => {
  const { unstyled, classNames, styles, position, children, offset, disabled, marksOffset, inverted, containerProps, ...props } = _props;
  const stylesRest = { unstyled, classNames, styles, disabled };
  const stylesApi = { ...stylesRest, inverted };
  return (
    <Edge
      {...{
        el: "div",
        selector: "trackContainer",
        ref,
        ...stylesRest,
        ...containerProps
      }}
    >
      <Edge {...{ el: "div", selector: "track", ...stylesApi }}>
        <Edge
          {...{
            el: "div",
            selector: "bar",
            style: {
              "--slider-bar-width": `calc(${position}% + var(--slider-size))`,
              "--slider-bar-offset": `calc(${offset}% - var(--slider-size))`
            },
            ...stylesApi
          }}
        />
        {children}
        <SliderMarks {...{ ...props, offset: marksOffset, ...stylesApi }} />
      </Edge>
    </Edge>
  );
});
SliderTrack.displayName = "Slider/SliderTrack";

export interface SliderMarksProps extends ComponentProps<"div"> {
  marks: { value: number; label?: React.ReactNode }[] | undefined;
  min: number;
  max: number;
  value: number;
  offset: number | undefined;
  disabled: boolean | undefined;
  inverted: boolean | undefined;
}
export const SliderMarks = React.forwardRef<HTMLDivElement, SliderMarksProps>((_props, ref) => {
  const { unstyled, classNames, styles, marks, min, max, disabled, value, offset, inverted, ...props } = _props;
  const stylesRest = { unstyled, classNames, styles, disabled };

  if (!marks) return null;

  const items = marks.map((mark, index) => (
    <Edge
      key={index}
      {...{
        el: "div",
        selector: "markWrapper",
        ref,
        style: {
          "--mark-offset": `${getPosition({ value: mark.value, min, max })}%`
        },
        ...stylesRest,
        ...props
      }}
    >
      <Edge
        {...{
          el: "div",
          selector: "mark",
          "data-filled": isMarkFilled({ mark, value, offset, inverted }) ? "true" : undefined,
          ...stylesRest
        }}
      />
      {mark.label && <Edge {...{ el: "div", selector: "markLabel", ...stylesRest }}>{mark.label}</Edge>}
    </Edge>
  ));

  return items.length ? <div>{items}</div> : null;
});
SliderMarks.displayName = "Slider/SliderMarks";

export interface ThumbProps extends ComponentProps<"div", "value"> {
  max: number;
  min: number;
  value: number;
  position: number;
  dragging: boolean;
  label: React.ReactNode;
  onKeyDownCapture?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
  onMouseDown?: (event: any) => void;
  labelTransitionProps: TransitionOverride | undefined;
  labelAlwaysOn: boolean | undefined;
  thumbLabel: string | undefined;
  showLabelOnHover: boolean | undefined;
  isHovered?: boolean;
  children?: React.ReactNode;
  disabled: boolean | undefined;
  className?: string;
}
export const SliderThumb = React.forwardRef<HTMLDivElement, ThumbProps>((_props, ref) => {
  const {
    unstyled,
    classNames,
    styles,
    style,
    max,
    min,
    value,
    position,
    label,
    dragging,
    onMouseDown,
    onKeyDownCapture,
    labelTransitionProps,
    labelAlwaysOn,
    thumbLabel,
    onFocus,
    onBlur,
    isHovered,
    showLabelOnHover,
    children = null,
    disabled,
    role = "slider",
    ...props
  } = _props;

  const [focused, setFocused] = React.useState(false);
  const stylesApi = { unstyled, classNames, styles };
  const isVisible = labelAlwaysOn || dragging || focused || (showLabelOnHover && isHovered);

  const handleFocus = (event: React.FocusEvent<HTMLDivElement, Element>) => {
    setFocused(true);
    typeof onFocus === "function" && onFocus(event);
  };
  const handleBlur = (event: React.FocusEvent<HTMLDivElement, Element>) => {
    setFocused(false);
    typeof onBlur === "function" && onBlur(event);
  };
  const handleClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
    event.stopPropagation();
  };

  return (
    <Edge
      {...{
        el: "div",
        selector: "thumb",
        ref,
        role,
        disabled,
        tabIndex: 0,
        "aria-label": thumbLabel,
        "aria-valuemax": max,
        "aria-valuemin": min,
        "aria-valuenow": value,
        "data-dragging": dragging ? "true" : undefined,
        onTouchStart: onMouseDown,
        onMouseDown: onMouseDown,
        onKeyDownCapture: onKeyDownCapture,
        onFocus: handleFocus,
        onBlur: handleBlur,
        onClick: handleClick,
        style: { "--slider-thumb-offset": `${position}%`, ...style },
        ...stylesApi,
        ...props
      }}
    >
      {children}
      <Transition duration={0} transition="fade" mounted={label != null && !!isVisible} {...labelTransitionProps}>
        {transitionStyles => <Edge {...{ el: "div", selector: "label", style: transitionStyles, ...stylesApi }}>{label}</Edge>}
      </Transition>
    </Edge>
  );
});
SliderThumb.displayName = "Slider/SliderThumb";

type EdgeType<T extends React.ElementType, Exclude extends string = never> = ComponentProps<T> &
  __SliderProps &
  Omit<
    {
      selector?: __Selector;
      el?: T;
      dir?: string;
      ref?: React.ComponentPropsWithRef<T>["ref"];
    },
    Exclude
  >;
const Edge = React.forwardRef(function Edge<T extends React.ElementType>(_props: EdgeType<T, "ref">, ref: React.ComponentPropsWithRef<T>["ref"]) {
  const { unstyled, className, classNames, el, style, styles, selector, size, thumbSize, round, disabled, color, inverted, dir, ...props } = _props;
  const ctx = useSlider();
  const Components = (el || "div") as React.ElementType;
  return (
    <Components
      {...{
        ref,
        dir: ctx?.dir ?? dir,
        ...ctx.getStyles(selector as __Selector, { unstyled, className, classNames, style, styles, size, thumbSize, color, inverted, disabled, round, ...ctx }),
        ...props
      }}
    />
  );
}) as <T extends React.ElementType>(_props: EdgeType<T>) => React.ReactElement;

interface GetChangeValue {
  value: number;
  containerWidth?: number;
  min: number;
  max: number;
  step: number;
  precision?: number;
}
export function getChangeValue({ value, containerWidth, min, max, step, precision }: GetChangeValue) {
  const left = !containerWidth ? value : Math.min(Math.max(value, 0), containerWidth) / containerWidth;
  const dx = left * (max - min);
  const nextValue = (dx !== 0 ? Math.round(dx / step) * step : 0) + min;
  const nextValueWithinStep = Math.max(nextValue, min);
  if (precision !== undefined) return Number(nextValueWithinStep.toFixed(precision));
  return nextValueWithinStep;
}

export function getPrecision(step: number) {
  if (!step) return 0;
  const split = step.toString().split(".");
  return split.length > 1 ? split[1].length : 0;
}

interface GetPosition {
  value: number;
  min: number;
  max: number;
}
export function getPosition({ value, min, max }: GetPosition) {
  const position = ((value - min) / (max - min)) * 100;
  return Math.min(Math.max(position, 0), 100);
}

export function findClosestNumber(value: number, numbers: number[]): number {
  if (numbers.length === 0) return value;
  return numbers.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
}

export function getNextMarkValue(currentValue: number, marks: { value: number; label?: React.ReactNode }[]) {
  const sortedMarks = [...marks].sort((a, b) => a.value - b.value);
  const nextMark = sortedMarks.find(mark => mark.value > currentValue);
  return nextMark ? nextMark.value : currentValue;
}

export function getPreviousMarkValue(currentValue: number, marks: { value: number; label?: React.ReactNode }[]) {
  const sortedMarks = [...marks].sort((a, b) => b.value - a.value);
  const previousMark = sortedMarks.find(mark => mark.value < currentValue);
  return previousMark ? previousMark.value : currentValue;
}

export function getFirstMarkValue(marks: { value: number; label?: React.ReactNode }[]) {
  const sortedMarks = [...marks].sort((a, b) => b.value - a.value);
  return sortedMarks.length > 0 ? sortedMarks[0].value : 0;
}

export function getLastMarkValue(marks: { value: number; label?: React.ReactNode }[]) {
  const sortedMarks = [...marks].sort((a, b) => a.value - b.value);
  return sortedMarks.length > 0 ? sortedMarks[sortedMarks.length - 1].value : 100;
}

export function getFloatingValue(value: number, precision: number) {
  return parseFloat(value.toFixed(precision));
}

export function getClientPosition(event: any) {
  if ("TouchEvent" in window && event instanceof window.TouchEvent) {
    const touch = event.touches[0];
    return touch.clientX;
  }
  return event.clientX;
}

interface IsMarkFilled {
  mark: { value: number; label?: any };
  offset?: number;
  value: number;
  inverted?: boolean;
}
export function isMarkFilled({ mark, offset, value, inverted = false }: IsMarkFilled) {
  return inverted
    ? typeof offset === "number"
      ? mark.value <= offset || mark.value >= value
      : mark.value >= value
    : typeof offset === "number"
      ? mark.value >= offset && mark.value <= value
      : mark.value <= value;
}

// Export as a composite component
type SliderComponent = React.ForwardRefExoticComponent<SliderProps> & {
  Track: typeof SliderTrack;
  Thumb: typeof SliderThumb;
  Marks: typeof SliderMarks;
};
// Attach sub-components
Slider.Track = SliderTrack;
Slider.Thumb = SliderThumb;
Slider.Marks = SliderMarks;