shadimage
amplo
uifill-picker-enginesource: ok · hit

Amplo Fill Picker Engine

Dialect-agnostic core of the Amplo fill picker: the solid/gradient mode state (fill context + `useFillPicker`) and the FillPicker.Root/Tabs/Tab/Pane parts both variants share. Installed automatically as a dependency of `fill-picker` (Base UI) and `fill-picker-radix`; you do not normally add it directly.

npx shadcn add "https://amplo.ale.design/r/fill-picker-engine.json"
free

Where it goes

stats

Visual style

gradient

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

utilshttps://amplo.ale.design/r/gradient-picker-engine.json

Files5 · source cached 10/14/2026, 2:27:41 PM

registry/new-york/ui/fill-picker/contexts/fill.ts
"use client";

import * as React from "react";
import type { FillPickerState } from "../hooks/use-fill-picker";

export const FillPickerContext = React.createContext<FillPickerState | null>(null);

/**
 * Stable id prefix provided by <FillPicker.Root> so Tab (`aria-controls`)
 * and Pane (`aria-labelledby`) can reference each other across the tree.
 */
export const FillPickerIdContext = React.createContext<string>("fill-picker");

export const fillTabId = (base: string, mode: string) => `${base}-tab-${mode}`;
export const fillPaneId = (base: string, mode: string) => `${base}-pane-${mode}`;

export function useFillPickerContext(): FillPickerState {
  const ctx = React.useContext(FillPickerContext);
  if (!ctx) {
    throw new Error(
      "FillPicker.Tabs / Tab / Pane must be rendered inside <FillPicker.Root>",
    );
  }
  return ctx;
}
registry/new-york/ui/fill-picker/hooks/use-fill-picker.ts
"use client";

import * as React from "react";
import {
  DEFAULT_LINEAR,
  formatFill,
  type Fill,
  type ColorFill,
  type GradientFill,
} from "../lib/gradient";
import type { OklchColor } from "../lib/types";

const DEFAULT_COLOR: OklchColor = { l: 0, c: 0, h: 0, alpha: 1 };

export type FillMode = "color" | "gradient";

export interface UseFillPickerProps {
  value?: Fill;
  defaultValue?: Fill;
  onValueChange?: (fill: Fill, css: string) => void;
  mode?: FillMode;
  defaultMode?: FillMode;
  onModeChange?: (mode: FillMode) => void;
}

export interface FillPickerState {
  fill: Fill;
  mode: FillMode;
  setFill: (fill: Fill) => void;
  setMode: (mode: FillMode) => void;
}

export function useFillPicker(props: UseFillPickerProps = {}): FillPickerState {
  const {
    value,
    defaultValue,
    onValueChange,
    mode: modeProp,
    defaultMode,
    onModeChange,
  } = props;

  // With no seed fill, the default fill follows the requested mode so
  // `defaultMode: "gradient"` doesn't start on a color fill.
  const seedFill = value ?? defaultValue;
  const initialMode: FillMode =
    modeProp ?? defaultMode ?? seedFill?.kind ?? "color";
  const initialFill: Fill =
    seedFill ??
    (initialMode === "gradient"
      ? { kind: "gradient", gradient: DEFAULT_LINEAR }
      : { kind: "color", color: DEFAULT_COLOR });

  const [internalFill, setInternalFill] = React.useState<Fill>(initialFill);
  const [internalMode, setInternalMode] = React.useState<FillMode>(initialMode);

  // Derive in render — don't sync props → state via useEffect (per React's
  // "you might not need an effect" guidance). The controlled value is the
  // source of truth when provided; internal state is only consulted when
  // uncontrolled.
  const isControlled = value !== undefined;
  const isControlledMode = modeProp !== undefined;
  const fill: Fill = isControlled ? value : internalFill;
  const mode: FillMode = isControlledMode ? modeProp : internalMode;

  // Track the last committed fill for each kind so setMode can restore the
  // cached side. A layout effect updates the cache before input can arrive
  // without mutating refs during render.
  const lastColorRef = React.useRef<ColorFill>(
    initialFill.kind === "color"
      ? initialFill
      : { kind: "color", color: DEFAULT_COLOR },
  );
  const lastGradientRef = React.useRef<GradientFill>(
    initialFill.kind === "gradient"
      ? initialFill
      : { kind: "gradient", gradient: DEFAULT_LINEAR },
  );
  React.useLayoutEffect(() => {
    if (fill.kind === "color") lastColorRef.current = fill;
    else lastGradientRef.current = fill;
  }, [fill]);

  const setFill = React.useCallback(
    (next: Fill) => {
      if (!isControlled) setInternalFill(next);
      onValueChange?.(next, formatFill(next));
    },
    [isControlled, onValueChange],
  );

  const setMode = React.useCallback(
    (next: FillMode) => {
      if (!isControlledMode) setInternalMode(next);
      onModeChange?.(next);
      const restored: Fill =
        next === "color" ? lastColorRef.current : lastGradientRef.current;
      if (!isControlled) setInternalFill(restored);
      onValueChange?.(restored, formatFill(restored));
    },
    [isControlled, isControlledMode, onModeChange, onValueChange],
  );

  return { fill, mode, setFill, setMode };
}
registry/new-york/ui/fill-picker/parts/fill/root.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { FillPickerContext, FillPickerIdContext } from "../../contexts/fill";
import {
  useFillPicker,
  type UseFillPickerProps,
} from "../../hooks/use-fill-picker";

export interface RootProps
  extends UseFillPickerProps,
    Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {}

export const Root = React.forwardRef<HTMLDivElement, RootProps>(function Root(
  {
    value,
    defaultValue,
    onValueChange,
    mode,
    defaultMode,
    onModeChange,
    className,
    children,
    ...rest
  },
  ref,
) {
  const state = useFillPicker({
    value,
    defaultValue,
    onValueChange,
    mode,
    defaultMode,
    onModeChange,
  });

  // Animate the Root's height as panes swap between Solid and Gradient
  // (and as the gradient pane itself grows/shrinks with shape controls).
  // Pure CSS `transition: height` can't animate to/from `auto`, so we
  // observe the live content height via ResizeObserver and pin it as an
  // inline pixel value — the transition takes care of the rest. No
  // dependencies, no measurement pass: the observer fires the moment
  // React commits the new tree.
  const innerRef = React.useRef<HTMLDivElement | null>(null);
  const [innerHeight, setInnerHeight] = React.useState<number | null>(null);
  React.useLayoutEffect(() => {
    const el = innerRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(([entry]) => {
      // border-box so padding inside `innerRef` is included.
      const box = entry.borderBoxSize?.[0];
      setInnerHeight(box ? box.blockSize : entry.contentRect.height);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  // Skip the very first commit so the picker mounts at its natural
  // height instead of animating up from 0.
  const animateRef = React.useRef(false);
  React.useEffect(() => {
    if (innerHeight !== null) animateRef.current = true;
  }, [innerHeight]);
  const idBase = React.useId();
  return (
    <FillPickerContext.Provider value={state}>
      <FillPickerIdContext.Provider value={idBase}>
      <div
        ref={ref}
        data-slot="fill-picker"
        className={cn(
          "w-full max-w-70 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-sm",
          animateRef.current &&
            "transition-[height] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
          className,
        )}
        {...rest}
        style={
          innerHeight !== null ? { height: innerHeight, ...rest.style } : rest.style
        }
      >
        <div ref={innerRef} className="flex flex-col gap-2 p-3">
          {children}
        </div>
      </div>
      </FillPickerIdContext.Provider>
    </FillPickerContext.Provider>
  );
});
registry/new-york/ui/fill-picker/parts/fill/tabs.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import {
  fillPaneId,
  fillTabId,
  FillPickerIdContext,
  useFillPickerContext,
} from "../../contexts/fill";
import type { FillMode } from "../../hooks/use-fill-picker";

export const Tabs = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function Tabs({ className, children, ...rest }, ref) {
  // APG Tabs pattern: Left/Right arrows rove between tabs and select as
  // they go (selection follows focus — panes render instantly). Activating
  // via .click() keeps any consumer onClick on the Tab in the loop.
  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const keys = ["ArrowRight", "ArrowLeft", "Home", "End"];
    if (!keys.includes(e.key)) return;
    const tabs = Array.from(
      e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]'),
    );
    if (tabs.length === 0) return;
    const current = tabs.indexOf(
      document.activeElement as HTMLButtonElement,
    );
    let next: number;
    if (e.key === "Home") next = 0;
    else if (e.key === "End") next = tabs.length - 1;
    else {
      const delta = e.key === "ArrowRight" ? 1 : -1;
      next = (Math.max(current, 0) + delta + tabs.length) % tabs.length;
    }
    e.preventDefault();
    tabs[next].focus();
    tabs[next].click();
  };
  return (
    <div
      ref={ref}
      role="tablist"
      aria-label="Fill mode"
      onKeyDown={onKeyDown}
      className={cn("inline-flex items-center gap-1 rounded-md bg-muted p-1", className)}
      {...rest}
    >
      {children}
    </div>
  );
});

export interface TabProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  mode: FillMode;
}

export const Tab = React.forwardRef<HTMLButtonElement, TabProps>(function Tab(
  { mode, className, children, ...rest },
  ref,
) {
  const ctx = useFillPickerContext();
  const idBase = React.useContext(FillPickerIdContext);
  const active = ctx.mode === mode;
  return (
    <button
      ref={ref}
      type="button"
      role="tab"
      id={fillTabId(idBase, mode)}
      aria-controls={fillPaneId(idBase, mode)}
      aria-selected={active}
      tabIndex={active ? 0 : -1}
      data-state={active ? "active" : "inactive"}
      onClick={() => ctx.setMode(mode)}
      className={cn(
        "rounded-sm px-3 py-1 text-xs font-medium outline-none transition-colors",
        "focus-visible:ring-2 focus-visible:ring-ring",
        active
          ? "bg-background text-foreground shadow-sm"
          : "text-muted-foreground hover:text-foreground",
        className,
      )}
      {...rest}
    >
      {children}
    </button>
  );
});
registry/new-york/ui/fill-picker/parts/fill/pane.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { ColorPickerContext } from "../../context";
import { GradientPickerContext } from "../../contexts/gradient";
import {
  fillPaneId,
  fillTabId,
  FillPickerIdContext,
  useFillPickerContext,
} from "../../contexts/fill";
import { useColorPicker } from "../../hooks/use-color-picker";
import { useGradientPicker } from "../../hooks/use-gradient-picker";
import type { FillMode } from "../../hooks/use-fill-picker";
import { DEFAULT_LINEAR, type Gradient } from "../../lib/gradient";
import type { OklchColor } from "../../lib/types";

export interface PaneProps extends React.HTMLAttributes<HTMLDivElement> {
  mode: FillMode;
}

export const Pane = React.forwardRef<HTMLDivElement, PaneProps>(function Pane(
  { mode, className, children, ...rest },
  ref,
) {
  const fill = useFillPickerContext();
  const idBase = React.useContext(FillPickerIdContext);
  if (fill.mode !== mode) return null;

  // APG tabpanel wiring: named by its tab, referenced by aria-controls.
  const panelProps = {
    role: "tabpanel" as const,
    id: fillPaneId(idBase, mode),
    "aria-labelledby": fillTabId(idBase, mode),
  };

  if (mode === "color") {
    return (
      <ColorPaneInner ref={ref} className={className} {...panelProps} {...rest}>
        {children}
      </ColorPaneInner>
    );
  }
  return (
    <GradientPaneInner ref={ref} className={className} {...panelProps} {...rest}>
      {children}
    </GradientPaneInner>
  );
});

/**
 * Tiny mount-fade hook. Pane swaps unmount one tree and mount another;
 * starting at opacity 0 and flipping to 1 on the next frame gives a
 * dependency-free crossfade. Two RAFs because a single one can land in
 * the same browser tick as the React commit on some engines.
 */
function useMountFade() {
  const [visible, setVisible] = React.useState(false);
  React.useEffect(() => {
    let raf2 = 0;
    const raf1 = requestAnimationFrame(() => {
      raf2 = requestAnimationFrame(() => setVisible(true));
    });
    return () => {
      cancelAnimationFrame(raf1);
      cancelAnimationFrame(raf2);
    };
  }, []);
  return visible;
}

const ColorPaneInner = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function ColorPaneInner({ className, children, ...rest }, ref) {
  const fill = useFillPickerContext();
  const colorValue: OklchColor =
    fill.fill.kind === "color"
      ? fill.fill.color
      : { l: 0, c: 0, h: 0, alpha: 1 };

  const setFill = fill.setFill;
  const onValueChange = React.useCallback(
    (color: OklchColor) => setFill({ kind: "color", color }),
    [setFill],
  );

  const state = useColorPicker({ value: colorValue, onValueChange });
  const visible = useMountFade();

  return (
    <ColorPickerContext.Provider value={state}>
      <div
        ref={ref}
        data-slot="fill-picker-pane"
        data-mode="color"
        className={cn(
          "transition-opacity duration-300 ease-in",
          visible ? "opacity-100" : "opacity-0",
          className,
        )}
        {...rest}
      >
        {children}
      </div>
    </ColorPickerContext.Provider>
  );
});

const GradientPaneInner = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function GradientPaneInner({ className, children, ...rest }, ref) {
  const fill = useFillPickerContext();
  const gradientValue: Gradient =
    fill.fill.kind === "gradient" ? fill.fill.gradient : DEFAULT_LINEAR;

  const setFill = fill.setFill;
  const onValueChange = React.useCallback(
    (gradient: Gradient) => setFill({ kind: "gradient", gradient }),
    [setFill],
  );

  const state = useGradientPicker({ value: gradientValue, onValueChange });
  const visible = useMountFade();

  return (
    <GradientPickerContext.Provider value={state}>
      <div
        ref={ref}
        data-slot="fill-picker-pane"
        data-mode="gradient"
        className={cn(
          "transition-opacity duration-300 ease-in",
          visible ? "opacity-100" : "opacity-0",
          className,
        )}
        {...rest}
      >
        {children}
      </div>
    </GradientPickerContext.Provider>
  );
});

More from amplo

Browse all components in this library →