Game UI

Menu slider

A value on a menu row, never a native range input.

stablev0.5.0 Chromium 103 ui-scale safe

Install

Copies the file into components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.
bunx shadcn@3 add @fastrp/menu-slider

Examples

Default

Open alone

API

<MenuSlider />

A value on a menu row: a face's width, a colour's strength. ← → move it while the row has the cursor; ↑ ↓ stay the list's, to move to the next row. Built on Radix's slider — never a native `<input type="range">`, which in the FiveM NUI takes the keyboard from the game until it loses focus.

PropTypeDefaultNotes
label*ReactNode—
onValueChange*(value: number) => void—
value*number—
classNamestring—
disabledboolean—
format((value: number) => ReactNode)—How the value reads on the right: `(v) => \`${v}%\``. The number when left out.
maxnumber100
minnumber0
stepnumber1

Dependencies

npm

  • @radix-ui/react-slider@^1.2.2

Source

components/ds/menu-slider.tsxShow
'use client';

import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import {
  focusOnHover,
  menuRowClassName,
  menuRowWithinClassName,
  useMenuListNav,
} from '@/components/ds/menu-list';
import { useUiSound } from '@/components/ds/ui-sounds';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

export type MenuSliderProps = {
  label: React.ReactNode;
  value: number;
  onValueChange: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** How the value reads on the right: `(v) => \`${v}%\``. The number when left out. */
  format?: (value: number) => React.ReactNode;
  disabled?: boolean;
  className?: string;
};

/**
 * A value on a menu row: a face's width, a colour's strength. ← → move it while the row has the
 * cursor; ↑ ↓ stay the list's, to move to the next row. Built on Radix's slider — never a native
 * `<input type="range">`, which in the FiveM NUI takes the keyboard from the game until it loses
 * focus.
 */
function MenuSlider({
  label,
  value,
  onValueChange,
  min = 0,
  max = 100,
  step = 1,
  format,
  disabled,
  className,
}: MenuSliderProps) {
  const nav = useMenuListNav();
  const sound = useUiSound();
  const lastTick = React.useRef(0);

  return (
    <div
      data-slot="menu-slider"
      className={cn(menuRowClassName, menuRowWithinClassName, 'gap-5', className)}
      // Radix would take ↑ ↓ as "more / less"; in a menu they move between rows.
      onKeyDownCapture={(event) => {
        if (nav && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
          event.preventDefault();
          event.stopPropagation();
          nav.move(event.target as Element, event.key === 'ArrowDown' ? 1 : -1);
        }
      }}
      data-sfx-hover="navigate"
      onPointerEnter={(event) => {
        const thumb = event.currentTarget.querySelector<HTMLElement>('[role="slider"]');
        if (event.pointerType === 'mouse' && thumb && document.activeElement !== thumb) {
          thumb.focus({ preventScroll: true });
        }
      }}
    >
      <span className="w-[38%] min-w-0 shrink-0 truncate">{label}</span>
      <SliderPrimitive.Root
        value={[value]}
        onValueChange={([next]) => {
          if (next === undefined) return;
          // A drag steps dozens of times a second; tick at most every 70ms, as a ratchet would.
          const now = performance.now();
          if (now - lastTick.current > 70) {
            lastTick.current = now;
            sound('adjust');
          }
          onValueChange(next);
        }}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        className="relative flex h-full flex-1 touch-none select-none items-center data-[disabled]:opacity-40"
      >
        <SliderPrimitive.Track className="relative h-1 flex-1 overflow-hidden rounded-full bg-foreground/20 group-focus-within/row:bg-background/20">
          <SliderPrimitive.Range className="absolute h-full bg-foreground group-focus-within/row:bg-background" />
        </SliderPrimitive.Track>
        <SliderPrimitive.Thumb
          data-menu-row
          aria-label={typeof label === 'string' ? label : undefined}
          onPointerEnter={focusOnHover}
          className="block h-4 w-1.5 rounded-[1px] bg-foreground shadow-[0_0_0.5rem_rgb(0_0_0/0.5)] outline-none transition-transform duration-fast focus:scale-125 group-focus-within/row:bg-background"
        />
      </SliderPrimitive.Root>
      <span className="w-12 shrink-0 text-right tabular-nums">
        {format ? format(value) : value}
      </span>
    </div>
  );
}

export { MenuSlider };