Web InnoventixFreeCode

Colour Gradient Picker

Original · free

gradient builder picker

byWeb InnoventixReact + Tailwind
colorgradientpickerpickers
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/color-gradient-picker.json
color-gradient-picker.tsx
"use client";

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

type GradientKind = "linear" | "radial" | "conic";

type ColorStop = {
  /** 0..1 position along the track */
  offset: number;
  /** hue 0..360 */
  h: number;
  /** saturation 0..100 */
  s: number;
  /** lightness 0..100 */
  l: number;
  /** alpha 0..1 */
  a: number;
};

type Stop = ColorStop & { id: string };

type Preset = {
  name: string;
  kind: GradientKind;
  angle: number;
  stops: ColorStop[];
};

const clamp = (value: number, min: number, max: number): number =>
  Math.min(max, Math.max(min, value));

const round = (value: number, places = 0): number => {
  const factor = 10 ** places;
  return Math.round(value * factor) / factor;
};

let stopSeq = 0;
const nextStopId = (): string => {
  stopSeq += 1;
  return `gs-${stopSeq}`;
};

const withIds = (stops: ColorStop[]): Stop[] =>
  stops.map((stop) => ({ ...stop, id: nextStopId() }));

function hslToRgb(h: number, s: number, l: number): [number, number, number] {
  const sat = s / 100;
  const lig = l / 100;
  const k = (n: number): number => (n + h / 30) % 12;
  const a = sat * Math.min(lig, 1 - lig);
  const f = (n: number): number =>
    lig - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [
    Math.round(f(0) * 255),
    Math.round(f(8) * 255),
    Math.round(f(4) * 255),
  ];
}

function toHex(stop: ColorStop): string {
  const [r, g, b] = hslToRgb(stop.h, stop.s, stop.l);
  const pair = (n: number): string => n.toString(16).padStart(2, "0");
  const base = `#${pair(r)}${pair(g)}${pair(b)}`;
  if (stop.a >= 1) return base.toUpperCase();
  return `${base}${pair(Math.round(stop.a * 255))}`.toUpperCase();
}

function stopColor(stop: ColorStop): string {
  return `hsl(${round(stop.h)} ${round(stop.s)}% ${round(stop.l)}% / ${round(stop.a, 2)})`;
}

/** A flat colour expressed as an image layer, so it can sit above the checkerboard. */
function flatLayer(stop: ColorStop): string {
  const color = stopColor(stop);
  return `linear-gradient(${color}, ${color})`;
}

function buildCss(
  kind: GradientKind,
  angle: number,
  stops: ColorStop[],
): string {
  const ordered = [...stops].sort((a, b) => a.offset - b.offset);
  const body = ordered
    .map((stop) => `${stopColor(stop)} ${round(stop.offset * 100, 1)}%`)
    .join(", ");
  if (kind === "linear") return `linear-gradient(${round(angle)}deg, ${body})`;
  if (kind === "conic")
    return `conic-gradient(from ${round(angle)}deg at 50% 50%, ${body})`;
  return `radial-gradient(circle at 50% 50%, ${body})`;
}

const PRESETS: Preset[] = [
  {
    name: "Solar Flare",
    kind: "linear",
    angle: 118,
    stops: [
      { offset: 0, h: 44, s: 96, l: 62, a: 1 },
      { offset: 0.52, h: 12, s: 88, l: 58, a: 1 },
      { offset: 1, h: 336, s: 72, l: 44, a: 1 },
    ],
  },
  {
    name: "Deep Signal",
    kind: "linear",
    angle: 205,
    stops: [
      { offset: 0, h: 199, s: 92, l: 60, a: 1 },
      { offset: 0.48, h: 244, s: 76, l: 55, a: 1 },
      { offset: 1, h: 268, s: 68, l: 32, a: 1 },
    ],
  },
  {
    name: "Moss Halo",
    kind: "radial",
    angle: 0,
    stops: [
      { offset: 0, h: 152, s: 78, l: 68, a: 1 },
      { offset: 0.62, h: 168, s: 58, l: 40, a: 1 },
      { offset: 1, h: 200, s: 44, l: 18, a: 1 },
    ],
  },
  {
    name: "Dial Tone",
    kind: "conic",
    angle: 90,
    stops: [
      { offset: 0, h: 262, s: 84, l: 62, a: 1 },
      { offset: 0.34, h: 330, s: 82, l: 60, a: 1 },
      { offset: 0.68, h: 26, s: 90, l: 60, a: 1 },
      { offset: 1, h: 262, s: 84, l: 62, a: 1 },
    ],
  },
  {
    name: "Cold Steel",
    kind: "linear",
    angle: 160,
    stops: [
      { offset: 0, h: 214, s: 24, l: 88, a: 1 },
      { offset: 0.55, h: 218, s: 18, l: 52, a: 1 },
      { offset: 1, h: 222, s: 26, l: 22, a: 1 },
    ],
  },
  {
    name: "Fade To Clear",
    kind: "linear",
    angle: 90,
    stops: [
      { offset: 0, h: 254, s: 88, l: 60, a: 1 },
      { offset: 1, h: 190, s: 92, l: 56, a: 0 },
    ],
  },
];

const CHECKERBOARD =
  "repeating-conic-gradient(rgba(120,120,130,0.28) 0% 25%, transparent 0% 50%) 50% / 12px 12px";

const KIND_LABEL: Record<GradientKind, string> = {
  linear: "Linear",
  radial: "Radial",
  conic: "Conic",
};

const ANGLE_LABEL: Record<GradientKind, string | null> = {
  linear: "Angle",
  radial: null,
  conic: "Start angle",
};

type SliderProps = {
  label: string;
  value: number;
  min: number;
  max: number;
  step: number;
  suffix?: string;
  trackStyle?: string;
  onChange: (value: number) => void;
};

function ChannelSlider({
  label,
  value,
  min,
  max,
  step,
  suffix = "",
  trackStyle,
  onChange,
}: SliderProps) {
  const id = useId();
  return (
    <div className="grid gap-1.5">
      <div className="flex items-baseline justify-between">
        <label
          htmlFor={id}
          className="text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400"
        >
          {label}
        </label>
        <span className="font-mono text-xs tabular-nums text-slate-700 dark:text-slate-200">
          {round(value, step < 1 ? 2 : 0)}
          {suffix}
        </span>
      </div>
      <div className="relative">
        {trackStyle ? (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-x-0 top-1/2 h-2.5 -translate-y-1/2 rounded-full ring-1 ring-inset ring-slate-900/10 dark:ring-white/15"
            style={{ background: trackStyle }}
          />
        ) : null}
        <input
          id={id}
          type="range"
          min={min}
          max={max}
          step={step}
          value={value}
          onChange={(event) => onChange(Number(event.target.value))}
          className="cgp-range relative w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-none"
        />
      </div>
    </div>
  );
}

export default function ColorGradientPicker() {
  const reduceMotion = useReducedMotion();
  const trackRef = useRef<HTMLDivElement | null>(null);
  const dragRef = useRef<string | null>(null);
  const copyTimer = useRef<number | null>(null);

  const [initialStops] = useState<Stop[]>(() => withIds(PRESETS[0].stops));
  const [kind, setKind] = useState<GradientKind>("linear");
  const [angle, setAngle] = useState<number>(118);
  const [stops, setStops] = useState<Stop[]>(initialStops);
  const [activeId, setActiveId] = useState<string>(initialStops[0].id);
  const [copied, setCopied] = useState<string | null>(null);

  const sorted = useMemo(
    () => [...stops].sort((a, b) => a.offset - b.offset),
    [stops],
  );
  const active = useMemo(
    () => stops.find((stop) => stop.id === activeId) ?? stops[0],
    [stops, activeId],
  );
  const css = useMemo(() => buildCss(kind, angle, stops), [kind, angle, stops]);
  const ribbon = useMemo(() => buildCss("linear", 90, stops), [stops]);

  useEffect(
    () => () => {
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    },
    [],
  );

  const patchActive = useCallback(
    (patch: Partial<Stop>) => {
      setStops((prev) =>
        prev.map((stop) => (stop.id === activeId ? { ...stop, ...patch } : stop)),
      );
    },
    [activeId],
  );

  const offsetFromClientX = useCallback((clientX: number): number => {
    const track = trackRef.current;
    if (!track) return 0;
    const rect = track.getBoundingClientRect();
    if (rect.width === 0) return 0;
    return clamp((clientX - rect.left) / rect.width, 0, 1);
  }, []);

  useEffect(() => {
    const handleMove = (event: PointerEvent): void => {
      const id = dragRef.current;
      if (id === null) return;
      event.preventDefault();
      const offset = offsetFromClientX(event.clientX);
      setStops((prev) =>
        prev.map((stop) => (stop.id === id ? { ...stop, offset } : stop)),
      );
    };
    const handleUp = (): void => {
      dragRef.current = null;
    };
    window.addEventListener("pointermove", handleMove);
    window.addEventListener("pointerup", handleUp);
    window.addEventListener("pointercancel", handleUp);
    return () => {
      window.removeEventListener("pointermove", handleMove);
      window.removeEventListener("pointerup", handleUp);
      window.removeEventListener("pointercancel", handleUp);
    };
  }, [offsetFromClientX]);

  const addStopAt = useCallback(
    (offset: number) => {
      if (stops.length >= 6) return;
      const ordered = [...stops].sort((a, b) => a.offset - b.offset);
      let before = ordered[0];
      let after = ordered[ordered.length - 1];
      for (let i = 0; i < ordered.length - 1; i += 1) {
        if (offset >= ordered[i].offset && offset <= ordered[i + 1].offset) {
          before = ordered[i];
          after = ordered[i + 1];
          break;
        }
      }
      const span = after.offset - before.offset;
      const t = span === 0 ? 0.5 : clamp((offset - before.offset) / span, 0, 1);
      const mix = (from: number, to: number): number => from + (to - from) * t;
      const created: Stop = {
        id: nextStopId(),
        offset,
        h: mix(before.h, after.h),
        s: mix(before.s, after.s),
        l: mix(before.l, after.l),
        a: mix(before.a, after.a),
      };
      setStops((prev) => [...prev, created]);
      setActiveId(created.id);
    },
    [stops],
  );

  const removeStop = useCallback(
    (id: string) => {
      if (stops.length <= 2) return;
      const next = stops.filter((stop) => stop.id !== id);
      setStops(next);
      if (id === activeId) setActiveId(next[0].id);
    },
    [stops, activeId],
  );

  const handleTrackPointerDown = (
    event: ReactPointerEvent<HTMLDivElement>,
  ): void => {
    if (event.target !== event.currentTarget) return;
    addStopAt(offsetFromClientX(event.clientX));
  };

  const handleThumbKeyDown = (
    event: ReactKeyboardEvent<HTMLButtonElement>,
    stop: Stop,
  ): void => {
    const step = event.shiftKey ? 0.1 : 0.01;
    let offset: number | null = null;
    if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
      offset = clamp(stop.offset - step, 0, 1);
    } else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
      offset = clamp(stop.offset + step, 0, 1);
    } else if (event.key === "Home") {
      offset = 0;
    } else if (event.key === "End") {
      offset = 1;
    } else if (event.key === "Delete" || event.key === "Backspace") {
      event.preventDefault();
      removeStop(stop.id);
      return;
    }
    if (offset === null) return;
    event.preventDefault();
    setActiveId(stop.id);
    const target = offset;
    setStops((prev) =>
      prev.map((item) =>
        item.id === stop.id ? { ...item, offset: target } : item,
      ),
    );
  };

  const applyPreset = (preset: Preset): void => {
    const next = withIds(preset.stops);
    setKind(preset.kind);
    setAngle(preset.angle);
    setStops(next);
    setActiveId(next[0].id);
  };

  const distributeEvenly = (): void => {
    const ordered = [...stops].sort((a, b) => a.offset - b.offset);
    const gap = ordered.length > 1 ? 1 / (ordered.length - 1) : 0;
    const spaced = new Map(
      ordered.map((stop, index) => [stop.id, index * gap] as const),
    );
    setStops((prev) =>
      prev.map((stop) => ({ ...stop, offset: spaced.get(stop.id) ?? stop.offset })),
    );
  };

  const reverseStops = (): void => {
    setStops((prev) => prev.map((stop) => ({ ...stop, offset: 1 - stop.offset })));
  };

  const copy = async (text: string, tag: string): Promise<void> => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(tag);
    } catch {
      setCopied("error");
    }
    if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => setCopied(null), 1600);
  };

  const hueTrack =
    "linear-gradient(90deg, hsl(0 90% 55%), hsl(60 90% 55%), hsl(120 90% 45%), hsl(180 90% 45%), hsl(240 90% 55%), hsl(300 90% 55%), hsl(360 90% 55%))";
  const satTrack = active
    ? `linear-gradient(90deg, hsl(${round(active.h)} 0% ${round(active.l)}%), hsl(${round(active.h)} 100% ${round(active.l)}%))`
    : undefined;
  const lightTrack = active
    ? `linear-gradient(90deg, #000, hsl(${round(active.h)} ${round(active.s)}% 50%), #fff)`
    : undefined;
  const alphaTrack = active
    ? `linear-gradient(90deg, hsl(${round(active.h)} ${round(active.s)}% ${round(active.l)}% / 0), hsl(${round(active.h)} ${round(active.s)}% ${round(active.l)}% / 1)), ${CHECKERBOARD}`
    : undefined;

  const tailwindSnippet = `bg-[${buildCss(kind, angle, stops).replace(/\s+/g, "_")}]`;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-50">
      <style>{`
        @keyframes cgpPickerPulse {
          0%, 100% { opacity: 0.55; transform: scale(1); }
          50% { opacity: 0.9; transform: scale(1.08); }
        }
        .cgp-range {
          height: 26px;
        }
        .cgp-range::-webkit-slider-runnable-track {
          height: 10px;
          border-radius: 999px;
          background: transparent;
        }
        .cgp-range::-moz-range-track {
          height: 10px;
          border-radius: 999px;
          background: transparent;
        }
        .cgp-range::-webkit-slider-thumb {
          -webkit-appearance: none;
          appearance: none;
          margin-top: -4px;
          height: 18px;
          width: 18px;
          border-radius: 999px;
          background: #ffffff;
          border: 1px solid rgba(15, 23, 42, 0.35);
          box-shadow: 0 1px 3px rgba(15, 23, 42, 0.35);
          cursor: grab;
        }
        .cgp-range::-moz-range-thumb {
          height: 18px;
          width: 18px;
          border-radius: 999px;
          background: #ffffff;
          border: 1px solid rgba(15, 23, 42, 0.35);
          box-shadow: 0 1px 3px rgba(15, 23, 42, 0.35);
          cursor: grab;
        }
        .cgp-range:focus-visible::-webkit-slider-thumb {
          outline: 2px solid #6366f1;
          outline-offset: 2px;
        }
        .cgp-range:focus-visible::-moz-range-thumb {
          outline: 2px solid #6366f1;
          outline-offset: 2px;
        }
        .cgp-pulse {
          animation: cgpPickerPulse 2.6s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .cgp-pulse {
            animation: none;
          }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="mb-8 flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="mb-2 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-medium uppercase tracking-[0.16em] text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
              <span
                className="cgp-pulse h-1.5 w-1.5 rounded-full bg-indigo-500"
                aria-hidden="true"
              />
              Gradient studio
            </p>
            <h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">
              Gradient builder
            </h2>
            <p className="mt-2 max-w-lg text-sm text-slate-600 dark:text-slate-400">
              Click the ribbon to drop a stop, drag to reposition, and tune hue,
              saturation, lightness and alpha per stop. Copy the CSS when it
              looks right.
            </p>
          </div>

          <div
            role="group"
            aria-label="Gradient type"
            className="inline-flex rounded-xl border border-slate-200 bg-white p-1 dark:border-slate-800 dark:bg-slate-900"
          >
            {(Object.keys(KIND_LABEL) as GradientKind[]).map((option) => (
              <button
                key={option}
                type="button"
                aria-pressed={kind === option}
                onClick={() => setKind(option)}
                className={`rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                  kind === option
                    ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
                    : "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
                }`}
              >
                {KIND_LABEL[option]}
              </button>
            ))}
          </div>
        </header>

        <div className="grid gap-6 lg:grid-cols-[1.15fr_1fr]">
          <div className="grid gap-5">
            <motion.div
              initial={reduceMotion ? false : { opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: reduceMotion ? 0 : 0.4, ease: "easeOut" }}
              className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
            >
              <div
                className="h-56 w-full sm:h-72"
                style={{ background: CHECKERBOARD }}
              >
                <div
                  className="h-full w-full"
                  style={{ background: css }}
                  role="img"
                  aria-label={`${KIND_LABEL[kind]} gradient preview with ${stops.length} colour stops`}
                />
              </div>

              <div className="border-t border-slate-200 p-4 dark:border-slate-800">
                <div className="mb-2 flex items-center justify-between">
                  <span className="text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
                    Stops ({stops.length}/6)
                  </span>
                  <span className="text-[11px] text-slate-400 dark:text-slate-500">
                    Click track to add · Delete to remove
                  </span>
                </div>

                <div className="pb-1 pt-1">
                  <div
                    ref={trackRef}
                    onPointerDown={handleTrackPointerDown}
                    className="relative h-9 w-full cursor-copy rounded-lg ring-1 ring-inset ring-slate-900/10 dark:ring-white/15"
                    style={{ background: `${ribbon}, ${CHECKERBOARD}` }}
                  >
                    {sorted.map((stop) => {
                      const isActive = stop.id === active?.id;
                      return (
                        <button
                          key={stop.id}
                          type="button"
                          role="slider"
                          aria-label={`Stop at ${round(stop.offset * 100)} percent, ${toHex(stop)}`}
                          aria-valuemin={0}
                          aria-valuemax={100}
                          aria-valuenow={round(stop.offset * 100)}
                          aria-valuetext={`${round(stop.offset * 100)} percent`}
                          onPointerDown={(event) => {
                            event.stopPropagation();
                            dragRef.current = stop.id;
                            setActiveId(stop.id);
                          }}
                          onKeyDown={(event) => handleThumbKeyDown(event, stop)}
                          onFocus={() => setActiveId(stop.id)}
                          className={`absolute top-1/2 h-7 w-7 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none rounded-full border-2 shadow-md transition-[box-shadow,border-color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:cursor-grabbing dark:focus-visible:ring-offset-slate-900 ${
                            isActive
                              ? "z-20 border-white ring-2 ring-slate-900 dark:border-slate-900 dark:ring-white"
                              : "z-10 border-white/90 dark:border-slate-900/90"
                          }`}
                          style={{
                            left: `${stop.offset * 100}%`,
                            background: `${flatLayer(stop)}, ${CHECKERBOARD}`,
                          }}
                        />
                      );
                    })}
                  </div>
                </div>

                <div className="mt-4 flex flex-wrap gap-2">
                  <button
                    type="button"
                    onClick={distributeEvenly}
                    className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white 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 16 16"
                      className="h-3.5 w-3.5"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.5"
                      strokeLinecap="round"
                      aria-hidden="true"
                    >
                      <path d="M2 3v10M8 3v10M14 3v10" />
                    </svg>
                    Distribute
                  </button>
                  <button
                    type="button"
                    onClick={reverseStops}
                    className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white 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 16 16"
                      className="h-3.5 w-3.5"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.5"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M3 6h10L10 3M13 10H3l3 3" />
                    </svg>
                    Reverse
                  </button>
                  <button
                    type="button"
                    onClick={() => addStopAt(0.5)}
                    disabled={stops.length >= 6}
                    className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 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 16 16"
                      className="h-3.5 w-3.5"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.5"
                      strokeLinecap="round"
                      aria-hidden="true"
                    >
                      <path d="M8 3v10M3 8h10" />
                    </svg>
                    Add stop
                  </button>
                  <button
                    type="button"
                    onClick={() => active && removeStop(active.id)}
                    disabled={stops.length <= 2}
                    className="inline-flex items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 py-1.5 text-xs font-medium text-rose-600 transition-colors hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-rose-900 dark:bg-slate-800 dark:text-rose-400 dark:hover:bg-rose-950 dark:focus-visible:ring-offset-slate-900"
                  >
                    <svg
                      viewBox="0 0 16 16"
                      className="h-3.5 w-3.5"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.5"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.5 8h6l.5-8" />
                    </svg>
                    Remove
                  </button>
                </div>
              </div>
            </motion.div>

            <div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
              <span className="mb-3 block text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
                Presets
              </span>
              <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
                {PRESETS.map((preset) => (
                  <button
                    key={preset.name}
                    type="button"
                    onClick={() => applyPreset(preset)}
                    className="group flex items-center gap-2.5 rounded-xl border border-slate-200 bg-white p-2 text-left transition-colors hover:border-slate-300 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                  >
                    <span
                      aria-hidden="true"
                      className="h-8 w-8 shrink-0 rounded-lg ring-1 ring-inset ring-slate-900/10 dark:ring-white/15"
                      style={{
                        background: `${buildCss(preset.kind, preset.angle, preset.stops)}, ${CHECKERBOARD}`,
                      }}
                    />
                    <span className="truncate text-xs font-medium text-slate-700 dark:text-slate-300">
                      {preset.name}
                    </span>
                  </button>
                ))}
              </div>
            </div>
          </div>

          <div className="grid gap-5">
            <div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
              <div className="mb-4 flex items-center justify-between gap-3">
                <span className="text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
                  Selected stop
                </span>
                {active ? (
                  <span className="inline-flex items-center gap-2">
                    <span
                      aria-hidden="true"
                      className="h-5 w-5 rounded-md ring-1 ring-inset ring-slate-900/15 dark:ring-white/20"
                      style={{
                        background: `${flatLayer(active)}, ${CHECKERBOARD}`,
                      }}
                    />
                    <span className="font-mono text-xs tabular-nums text-slate-700 dark:text-slate-200">
                      {toHex(active)}
                    </span>
                  </span>
                ) : null}
              </div>

              {active ? (
                <div className="grid gap-4">
                  <ChannelSlider
                    label="Hue"
                    value={round(active.h)}
                    min={0}
                    max={360}
                    step={1}
                    suffix="°"
                    trackStyle={hueTrack}
                    onChange={(value) => patchActive({ h: value })}
                  />
                  <ChannelSlider
                    label="Saturation"
                    value={round(active.s)}
                    min={0}
                    max={100}
                    step={1}
                    suffix="%"
                    trackStyle={satTrack}
                    onChange={(value) => patchActive({ s: value })}
                  />
                  <ChannelSlider
                    label="Lightness"
                    value={round(active.l)}
                    min={0}
                    max={100}
                    step={1}
                    suffix="%"
                    trackStyle={lightTrack}
                    onChange={(value) => patchActive({ l: value })}
                  />
                  <ChannelSlider
                    label="Alpha"
                    value={round(active.a, 2)}
                    min={0}
                    max={1}
                    step={0.01}
                    trackStyle={alphaTrack}
                    onChange={(value) => patchActive({ a: value })}
                  />
                  <ChannelSlider
                    label="Position"
                    value={round(active.offset * 100)}
                    min={0}
                    max={100}
                    step={1}
                    suffix="%"
                    onChange={(value) => patchActive({ offset: value / 100 })}
                  />
                  {ANGLE_LABEL[kind] ? (
                    <ChannelSlider
                      label={ANGLE_LABEL[kind] as string}
                      value={angle}
                      min={0}
                      max={360}
                      step={1}
                      suffix="°"
                      onChange={setAngle}
                    />
                  ) : (
                    <p className="rounded-lg bg-slate-50 px-3 py-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
                      Radial gradients ignore angle — the ramp runs outward from
                      the centre.
                    </p>
                  )}
                </div>
              ) : null}
            </div>

            <div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
              <div className="mb-3 flex items-center justify-between">
                <span className="text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
                  Output
                </span>
                <span
                  aria-live="polite"
                  className="text-[11px] font-medium text-emerald-600 dark:text-emerald-400"
                >
                  {copied === "css"
                    ? "CSS copied"
                    : copied === "tw"
                      ? "Class copied"
                      : copied === "error"
                        ? "Copy blocked"
                        : ""}
                </span>
              </div>

              <div className="grid gap-3">
                <div className="rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
                  <code className="block max-h-28 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed text-slate-700 dark:text-slate-300">
                    background: {css};
                  </code>
                </div>
                <div className="flex gap-2">
                  <button
                    type="button"
                    onClick={() => void copy(`background: ${css};`, "css")}
                    className="inline-flex flex-1 items-center justify-center gap-2 rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition-colors hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white dark:focus-visible:ring-offset-slate-900"
                  >
                    <svg
                      viewBox="0 0 16 16"
                      className="h-3.5 w-3.5"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.5"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
                      <path d="M10.5 5.5v-2a1.5 1.5 0 0 0-1.5-1.5H4a1.5 1.5 0 0 0-1.5 1.5V9A1.5 1.5 0 0 0 4 10.5h1.5" />
                    </svg>
                    Copy CSS
                  </button>
                  <button
                    type="button"
                    onClick={() => void copy(tailwindSnippet, "tw")}
                    className="inline-flex flex-1 items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
                  >
                    Copy Tailwind class
                  </button>
                </div>
              </div>

              <dl className="mt-4 grid grid-cols-3 gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
                <div>
                  <dt className="text-[10px] uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
                    Type
                  </dt>
                  <dd className="text-xs font-medium text-slate-700 dark:text-slate-300">
                    {KIND_LABEL[kind]}
                  </dd>
                </div>
                <div>
                  <dt className="text-[10px] uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
                    Stops
                  </dt>
                  <dd className="text-xs font-medium tabular-nums text-slate-700 dark:text-slate-300">
                    {stops.length}
                  </dd>
                </div>
                <div>
                  <dt className="text-[10px] uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
                    Angle
                  </dt>
                  <dd className="text-xs font-medium tabular-nums text-slate-700 dark:text-slate-300">
                    {kind === "radial" ? "n/a" : `${round(angle)}°`}
                  </dd>
                </div>
              </dl>
            </div>
          </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 →