Select
Dropdown that opens with a panel wipe and highlights rows through a continuous, proximity-weighted blend instead of snapping row to row.
Installation
npx shadcn add https://www.reelcn.dev/r/select.jsonFiles land in src/reelcn/ with the items it builds on.
Usage
<Center>
<Select
id="plan"
options={["Starter", "Growth", "Scale"]}
steps={[{ at: 0, state: "closed" }, { at: 0.5, state: "open" }, { at: 1.2, state: { highlight: 1 } }]}
/>
</Center>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.idstring
options*string[]
stepsStep<SelectState | { highlight: number; }>[]
placePlace
styleCSSProperties
classNamestring
Built from
Use
- A settings/plan picker in a UI-sim walkthrough
- A `ui` scene's `component: "select"`
Avoid
- A single fixed choice with no menu. Use plain text
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/select.tsx
/**
* @title Select
* @category product
* @description Dropdown that opens with a panel wipe and highlights rows through a continuous, proximity-weighted blend instead of snapping row to row.
* @duration data-driven
* @use A settings/plan picker in a UI-sim walkthrough
* @use A `ui` scene's `component: "select"`
* @avoid A single fixed choice with no menu. Use plain text
* @tags select, dropdown, menu, ui, kit
* @example
* <Center>
* <Select
* id="plan"
* options={["Starter", "Growth", "Scale"]}
* steps={[{ at: 0, state: "closed" }, { at: 0.5, state: "open" }, { at: 1.2, state: { highlight: 1 } }]}
* />
* </Center>
*/
import type React from "react";
import {
type AnchorRect,
alpha,
anchorId,
type MotionProps,
type Place,
proximityWeight,
type Step,
useKeyframeState,
useMotion,
useTextMetrics,
useTheme,
useViewport,
} from "./core";
export type SelectState = "closed" | "open";
export type SelectProps = MotionProps & {
id?: string;
options: string[];
steps?: Step<SelectState | { highlight: number }>[];
place?: Place;
style?: React.CSSProperties;
className?: string;
};
export const TRIGGER_HEIGHT = 52;
const ROW_HEIGHT = 44;
export const PAD_X = 16;
export const FONT_SIZE = 18;
const isHighlightState = (s: unknown): s is { highlight: number } =>
typeof s === "object" && s !== null && typeof (s as { highlight?: unknown }).highlight === "number";
/** Splits the one union-typed `steps` prop into two same-shaped folds the existing engine already knows
* how to blend: open/closed, and the highlighted row index. */
function useSelectFolds(steps: SelectProps["steps"]) {
const openSteps: Step<boolean>[] = (steps ?? []).map((s) => ({ at: s.at, state: s.state !== "closed" }));
const highlightSteps: Step<number>[] = (steps ?? [])
.filter((s) => isHighlightState(s.state))
.map((s) => ({ at: s.at, state: (s.state as { highlight: number }).highlight }));
const open = useKeyframeState<boolean>(openSteps, false);
const highlight = useKeyframeState<number>(highlightSteps, 0);
const openAmount = (open.from ? 1 : 0) + ((open.state ? 1 : 0) - (open.from ? 1 : 0)) * open.progress;
const offset = highlight.from + (highlight.state - highlight.from) * highlight.progress;
return { openAmount, offset };
}
/** Pure: given the longest option's already-measured width, the trigger's own box. Shared by
* `useSelectAnchors` and the `ui` scene (Task 9), ponytail-review blocker 2's "one formula" contract. */
export function selectBoxSize(u: (n: number) => number, measuredLongestWidth: number) {
return { width: measuredLongestWidth + u(PAD_X) * 2, height: u(TRIGGER_HEIGHT) };
}
/** Longest option's rendered width, the panel width every row shares, never a `len*8`px estimate. */
function usePanelWidth(options: string[], size: number, skip: boolean) {
const theme = useTheme();
const { u } = useViewport();
const longest = options.reduce((a, b) => (b.length > a.length ? b : a), "");
const metrics = useTextMetrics(
longest,
{ fontFamily: theme.fonts.body, fontSize: u(size), fontWeight: 500 },
{ skip },
);
return selectBoxSize(u, metrics.width).width;
}
export function useSelectAnchors(props: SelectProps): Record<string, AnchorRect> {
const { u, width, height } = useViewport();
const skip = !props.id || !props.place;
const panelW = usePanelWidth(props.options, FONT_SIZE, skip);
if (!props.id || !props.place) return {};
const wPct = (panelW / width) * 100;
const triggerHPct = (u(TRIGGER_HEIGHT) / height) * 100;
const x = props.place.x - wPct / 2;
const triggerY = props.place.y - triggerHPct / 2;
const anchors: Record<string, AnchorRect> = { [props.id]: { x, y: triggerY, width: wPct, height: triggerHPct } };
// Rows are anchored at their fully-open resting position, a target/click id is for a cursor arriving
// once the panel has settled, not a live-tracked position mid-wipe.
const rowHPct = (u(ROW_HEIGHT) / height) * 100;
const panelTop = triggerY + triggerHPct;
props.options.forEach((_, i) => {
anchors[anchorId(props.id as string, i)] = { x, y: panelTop + i * rowHPct, width: wPct, height: rowHPct };
});
return anchors;
}
export function Select({ id: _id, options, steps, place, style, className, ...motion }: SelectProps) {
const theme = useTheme();
const { u } = useViewport();
const m = useMotion(motion);
const { openAmount, offset } = useSelectFolds(steps);
const panelW = usePanelWidth(options, FONT_SIZE, false);
const open = Math.max(0, Math.min(1, openAmount));
return (
<div
className={className}
style={{
position: place ? "absolute" : "relative",
left: place ? `${place.x}%` : undefined,
top: place ? `${place.y}%` : undefined,
translate: place ? "-50% -50%" : undefined,
width: panelW,
opacity: m.presence,
...style,
}}
>
<div
style={{
height: u(TRIGGER_HEIGHT),
border: `${u(1.5)}px solid ${theme.colors.border}`,
borderRadius: u(theme.radius),
display: "flex",
alignItems: "center",
padding: `0 ${u(PAD_X)}px`,
background: theme.colors.surface,
color: theme.colors.foreground,
fontFamily: theme.fonts.body,
fontSize: u(FONT_SIZE),
}}
>
{options[Math.round(offset)] ?? options[0]}
</div>
<div
style={{
position: "relative",
height: u(ROW_HEIGHT) * options.length * open,
overflow: "hidden",
borderRadius: u(theme.radius),
background: theme.colors.surface,
boxShadow: open > 0.02 ? `0 ${u(8)}px ${u(20)}px rgba(0,0,0,0.2)` : "none",
}}
>
{options.map((option, i) => {
const w = proximityWeight(i, offset);
return (
<div
key={option}
style={{
position: "absolute",
top: u(ROW_HEIGHT) * i,
left: 0,
right: 0,
height: u(ROW_HEIGHT),
display: "flex",
alignItems: "center",
padding: `0 ${u(PAD_X)}px`,
background: alpha(theme.colors.accent, w * 0.16),
color: `color-mix(in srgb, ${theme.colors.accent} ${Math.round(w * 100)}%, ${theme.colors.foreground})`,
fontFamily: theme.fonts.body,
fontSize: u(FONT_SIZE),
}}
>
{option}
</div>
);
})}
</div>
</div>
);
}