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 · lightRegistry 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>
);
});