Web InnoventixFreeCode

Tooltip Slider

Original · free

slider with a floating value tooltip

byWeb InnoventixReact + Tailwind
sldtooltipsliders
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/sld-tooltip.json
sld-tooltip.tsx
"use client";

import {
  useCallback,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Accent = "indigo" | "emerald";

type AccentTokens = {
  fill: string;
  tip: string;
  thumbBorder: string;
  focusRing: string;
  glow: string;
};

const ACCENTS: Record<Accent, AccentTokens> = {
  indigo: {
    fill: "from-indigo-500 to-violet-500",
    tip: "bg-indigo-600 text-white",
    thumbBorder: "border-indigo-500 dark:border-indigo-400",
    focusRing: "focus-visible:ring-indigo-500",
    glow: "rgba(99, 102, 241, 0.55)",
  },
  emerald: {
    fill: "from-emerald-500 to-sky-500",
    tip: "bg-emerald-600 text-white",
    thumbBorder: "border-emerald-500 dark:border-emerald-400",
    focusRing: "focus-visible:ring-emerald-500",
    glow: "rgba(16, 185, 129, 0.55)",
  },
};

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

function decimalsOf(step: number): number {
  const parts = String(step).split(".");
  return parts.length > 1 ? parts[1].length : 0;
}

type TooltipSliderProps = {
  id: string;
  label: string;
  caption: string;
  min: number;
  max: number;
  step: number;
  value: number;
  onChange: (next: number) => void;
  format: (value: number) => string;
  accent: Accent;
};

function TooltipSlider({
  id,
  label,
  caption,
  min,
  max,
  step,
  value,
  onChange,
  format,
  accent,
}: TooltipSliderProps) {
  const reduce = useReducedMotion();
  const railRef = useRef<HTMLDivElement | null>(null);
  const thumbRef = useRef<HTMLDivElement | null>(null);
  const [dragging, setDragging] = useState(false);
  const [focused, setFocused] = useState(false);
  const [hovered, setHovered] = useState(false);

  const tokens = ACCENTS[accent];
  const percent = ((value - min) / (max - min)) * 100;
  const active = dragging || focused || hovered;
  const labelId = `${id}-label`;

  const commit = useCallback(
    (raw: number) => {
      const stepped = Math.round((raw - min) / step) * step + min;
      const bounded = clamp(stepped, min, max);
      const clean = Number(bounded.toFixed(decimalsOf(step)));
      if (clean !== value) onChange(clean);
    },
    [min, max, step, value, onChange],
  );

  const commitFromClientX = useCallback(
    (clientX: number) => {
      const rail = railRef.current;
      if (!rail) return;
      const rect = rail.getBoundingClientRect();
      if (rect.width === 0) return;
      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
      commit(min + ratio * (max - min));
    },
    [commit, min, max],
  );

  const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    event.preventDefault();
    setDragging(true);
    commitFromClientX(event.clientX);
    thumbRef.current?.focus();

    const move = (moveEvent: PointerEvent) => commitFromClientX(moveEvent.clientX);
    const up = () => {
      setDragging(false);
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    let next: number | null = null;
    switch (event.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = value + step;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = value - step;
        break;
      case "PageUp":
        next = value + step * 10;
        break;
      case "PageDown":
        next = value - step * 10;
        break;
      case "Home":
        next = min;
        break;
      case "End":
        next = max;
        break;
      default:
        return;
    }
    event.preventDefault();
    commit(next);
  };

  const tipMotion = reduce
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0 },
        transition: { duration: 0.12 },
      }
    : {
        initial: { opacity: 0, y: 6, scale: 0.9 },
        animate: { opacity: 1, y: 0, scale: 1 },
        exit: { opacity: 0, y: 6, scale: 0.9 },
        transition: { type: "spring" as const, stiffness: 520, damping: 30 },
      };

  return (
    <div className="w-full">
      <div className="mb-4 flex items-baseline justify-between gap-4">
        <div>
          <span
            id={labelId}
            className="block text-sm font-semibold tracking-tight text-slate-800 dark:text-slate-100"
          >
            {label}
          </span>
          <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
            {caption}
          </span>
        </div>
        <span className="shrink-0 font-mono text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
          {format(value)}
        </span>
      </div>

      <div
        className="relative cursor-pointer touch-none select-none py-3"
        onPointerDown={handlePointerDown}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
      >
        <div
          ref={railRef}
          className="relative h-2 w-full rounded-full bg-slate-200 dark:bg-slate-700"
        >
          <div
            className={`absolute inset-y-0 left-0 overflow-hidden rounded-full bg-gradient-to-r ${tokens.fill}`}
            style={{ width: `${percent}%` }}
          >
            <span
              aria-hidden="true"
              className="sldtt-shimmer pointer-events-none absolute inset-0"
              style={{
                backgroundImage:
                  "linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent)",
                backgroundSize: "220% 100%",
              }}
            />
          </div>

          <div
            className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
            style={{ left: `${percent}%` }}
          >
            <div className="pointer-events-none absolute bottom-full left-1/2 mb-3 -translate-x-1/2">
              <AnimatePresence>
                {active ? (
                  <motion.div
                    key="tip"
                    initial={tipMotion.initial}
                    animate={tipMotion.animate}
                    exit={tipMotion.exit}
                    transition={tipMotion.transition}
                    className={`relative whitespace-nowrap rounded-lg px-2.5 py-1 text-xs font-semibold tabular-nums shadow-lg ${tokens.tip}`}
                  >
                    {format(value)}
                    <span
                      aria-hidden="true"
                      className={`absolute left-1/2 top-full -mt-1 h-2 w-2 -translate-x-1/2 rotate-45 rounded-[2px] ${tokens.tip}`}
                    />
                  </motion.div>
                ) : null}
              </AnimatePresence>
            </div>

            <div
              ref={thumbRef}
              role="slider"
              tabIndex={0}
              aria-labelledby={labelId}
              aria-valuemin={min}
              aria-valuemax={max}
              aria-valuenow={value}
              aria-valuetext={format(value)}
              onKeyDown={handleKeyDown}
              onFocus={() => setFocused(true)}
              onBlur={() => setFocused(false)}
              className={`${dragging ? "sldtt-pulse " : ""}h-5 w-5 cursor-grab rounded-full border-2 bg-white shadow-md outline-none transition-transform duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:cursor-grabbing dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950 ${tokens.thumbBorder} ${tokens.focusRing} ${active ? "scale-110" : "scale-100"}`}
              style={{ "--sldtt-glow": tokens.glow } as CSSProperties}
            />
          </div>
        </div>

        <div className="mt-3 flex justify-between text-[11px] font-medium text-slate-400 dark:text-slate-500">
          <span>{format(min)}</span>
          <span>{format(max)}</span>
        </div>
      </div>
    </div>
  );
}

export default function SldTooltip() {
  const [weeklyBudget, setWeeklyBudget] = useState(4000);
  const [weeks, setWeeks] = useState(6);
  const [coverage, setCoverage] = useState(85);

  const defaults = { weeklyBudget: 4000, weeks: 6, coverage: 85 };
  const isDefault =
    weeklyBudget === defaults.weeklyBudget &&
    weeks === defaults.weeks &&
    coverage === defaults.coverage;

  const reset = () => {
    setWeeklyBudget(defaults.weeklyBudget);
    setWeeks(defaults.weeks);
    setCoverage(defaults.coverage);
  };

  const total = weeklyBudget * weeks;
  const currency = (value: number) => `$${value.toLocaleString("en-US")}`;

  return (
    <section className="relative w-full overflow-hidden bg-white px-6 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
      <style>{`
        @keyframes sldtt-shimmer {
          0% { background-position: 200% 0; }
          100% { background-position: -200% 0; }
        }
        @keyframes sldtt-pulse {
          0% { box-shadow: 0 0 0 0 var(--sldtt-glow); }
          70% { box-shadow: 0 0 0 10px rgba(0, 0, 0, 0); }
          100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
        }
        .sldtt-shimmer { animation: sldtt-shimmer 2.6s linear infinite; }
        .sldtt-pulse { animation: sldtt-pulse 1.4s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .sldtt-shimmer, .sldtt-pulse { animation: none !important; }
        }
      `}</style>

      <div className="pointer-events-none absolute -top-24 right-0 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10" />
      <div className="pointer-events-none absolute -bottom-24 -left-10 h-72 w-72 rounded-full bg-emerald-200/40 blur-3xl dark:bg-emerald-500/10" />

      <div className="relative mx-auto max-w-2xl">
        <div className="mb-10 text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <svg
              viewBox="0 0 24 24"
              aria-hidden="true"
              className="h-3.5 w-3.5 text-indigo-500 dark:text-indigo-400"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <path d="M4 12h16" />
              <circle cx="14" cy="12" r="3" fill="currentColor" stroke="none" />
            </svg>
            Campaign planner
          </span>
          <h2 className="mt-5 text-3xl font-bold tracking-tight sm:text-4xl">
            Shape your launch budget
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-slate-600 dark:text-slate-400">
            Drag each control or use the arrow keys. The floating tooltip tracks
            the thumb so you always see the live value.
          </p>
        </div>

        <div className="rounded-2xl border border-slate-200 bg-slate-50/70 p-6 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/60 sm:p-8">
          <div className="space-y-9">
            <TooltipSlider
              id="weekly-budget"
              label="Weekly ad spend"
              caption="Paid media budget per week"
              min={500}
              max={20000}
              step={250}
              value={weeklyBudget}
              onChange={setWeeklyBudget}
              format={currency}
              accent="indigo"
            />
            <TooltipSlider
              id="runway"
              label="Campaign runway"
              caption="How long the push runs"
              min={1}
              max={16}
              step={1}
              value={weeks}
              onChange={setWeeks}
              format={(value) => `${value} ${value === 1 ? "week" : "weeks"}`}
              accent="emerald"
            />
            <TooltipSlider
              id="coverage"
              label="Audience coverage"
              caption="Share of your target market reached"
              min={0}
              max={100}
              step={5}
              value={coverage}
              onChange={setCoverage}
              format={(value) => `${value}%`}
              accent="indigo"
            />
          </div>

          <div className="mt-8 flex flex-col gap-4 border-t border-slate-200 pt-6 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
            <p className="text-sm text-slate-600 dark:text-slate-400">
              Across{" "}
              <span className="font-semibold text-slate-900 dark:text-white">
                {weeks} {weeks === 1 ? "week" : "weeks"}
              </span>{" "}
              at{" "}
              <span className="font-semibold text-slate-900 dark:text-white">
                {currency(weeklyBudget)}/wk
              </span>
              , plan for about{" "}
              <span className="font-mono font-semibold tabular-nums text-indigo-600 dark:text-indigo-400">
                {currency(total)}
              </span>
              .
            </p>
            <button
              type="button"
              onClick={reset}
              disabled={isDefault}
              className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm font-medium text-slate-700 shadow-sm outline-none transition hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
            >
              <svg
                viewBox="0 0 24 24"
                aria-hidden="true"
                className="h-4 w-4"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
                <path d="M3 3v5h5" />
              </svg>
              Reset
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →