Select
React NativeA panel that unfolds out of its trigger.
Preview


Install
Source on GitHubIt works in Expo and in bare React Native. New to beUI Native? Set up your app first.
Copies the component and the shared files it needs into src/, adds the theme tokens if they're missing, and installs its libraries. Files you've changed are kept.
Keep the folder layout, since the component imports the shared files by relative path.
1Install the libraries
npx expo install react-native-reanimated react-native-worklets
npm install uniwind tailwindcss2Add the theme tokens
Once per app. Import it from the CSS file Uniwind reads, after Tailwind and Uniwind, and change the values to match your brand.
/* Matches the default beUI palette in ui-components/lib/themes.ts.
Native surface maps to card; muted maps to muted-foreground. */
@layer theme {
:root {
@variant light {
--color-background: oklch(99% 0 0);
--color-surface: oklch(97% 0 0);
--color-foreground: oklch(15% 0 0);
--color-muted: oklch(50% 0 0);
--color-border: oklch(15% 0 0 / 0.06);
--color-border-strong: oklch(15% 0 0 / 0.16);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #dc2626;
--color-success: #059669;
--color-warning: #d97706;
}
}
:root {
@variant dark {
--color-background: #151515;
--color-surface: #1c1c1c;
--color-foreground: oklch(96% 0 0);
--color-muted: oklch(62% 0 0);
--color-border: rgb(255 255 255 / 0.05);
--color-border-strong: rgb(255 255 255 / 0.14);
--color-primary: #0285f7;
--color-on-primary: #ffffff;
--color-destructive: #f87171;
--color-success: #34d399;
--color-warning: #fbbf24;
}
}
}
3Copy the shared files
import { useEffect, useState } from "react";
import { AccessibilityInfo } from "react-native";
import { useReducedMotion } from "react-native-reanimated";
/** Reanimated supplies a synchronous initial value; native events keep it current. */
export function useReducedMotionPreference() {
const initialValue = useReducedMotion();
const [reducedMotion, setReducedMotion] = useState(initialValue);
useEffect(() => {
let active = true;
let receivedEvent = false;
const subscription = AccessibilityInfo.addEventListener(
"reduceMotionChanged",
(value) => {
receivedEvent = true;
setReducedMotion(value);
},
);
void AccessibilityInfo.isReduceMotionEnabled().then((value) => {
if (active && !receivedEvent) setReducedMotion(value);
});
return () => {
active = false;
subscription.remove();
};
}, []);
return reducedMotion;
}
import { type ColorValue, StyleSheet, View } from "react-native";
import Animated, {
interpolate,
type SharedValue,
useAnimatedStyle,
} from "react-native-reanimated";
/** A straight line in a square viewBox: [x1, y1, x2, y2]. */
export type PathSegment = [x1: number, y1: number, x2: number, y2: number];
export type DrawnPathProps = {
/** Straight segments drawn in order, like an SVG polyline. */
segments: PathSegment[];
/** Rendered width and height in points. */
size: number;
/** Stroke width in viewBox units. */
strokeWidth: number;
/** Side of the square viewBox the segments are written in. Defaults to 24. */
viewBox?: number;
color: ColorValue | undefined;
/** 0 draws nothing and 1 draws every segment, like SVG `pathLength`. */
progress: SharedValue<number>;
};
function length([x1, y1, x2, y2]: PathSegment) {
return Math.hypot(x2 - x1, y2 - y1);
}
/**
* Draws a polyline on with rotated Views, so shared components need no SVG
* library. Round caps extend half a stroke past each end, like SVG's linecap.
*/
export function DrawnPath({
segments,
size,
strokeWidth,
viewBox = 24,
color,
progress,
}: DrawnPathProps) {
const unit = size / viewBox;
const stroke = strokeWidth * unit;
const total = segments.reduce((sum, segment) => sum + length(segment), 0);
let start = 0;
return (
<View style={{ width: size, height: size }}>
{segments.map((segment) => {
const from = start / total;
start += length(segment);
return (
<Stroke
key={segment.join()}
segment={segment}
unit={unit}
stroke={stroke}
from={from}
to={start / total}
progress={progress}
color={color}
/>
);
})}
</View>
);
}
function Stroke({
segment,
unit,
stroke,
from,
to,
progress,
color,
}: {
segment: PathSegment;
unit: number;
stroke: number;
from: number;
to: number;
progress: SharedValue<number>;
color: ColorValue | undefined;
}) {
const [x1, y1, x2, y2] = segment;
const full = length(segment) * unit;
const angle = Math.atan2(y2 - y1, x2 - x1);
const animatedStyle = useAnimatedStyle(() => {
const drawn = interpolate(progress.value, [from, to], [0, 1], "clamp");
return { opacity: drawn > 0 ? 1 : 0, width: stroke + full * drawn };
});
return (
<Animated.View
style={[
styles.stroke,
{
left: x1 * unit - stroke / 2,
top: y1 * unit - stroke / 2,
height: stroke,
borderRadius: stroke / 2,
backgroundColor: color,
// Rotate around the start point so the stroke grows from it.
transformOrigin: [stroke / 2, stroke / 2, 0],
transform: [{ rotate: `${angle}rad` }],
},
animatedStyle,
]}
/>
);
}
const styles = StyleSheet.create({
stroke: { position: "absolute" },
});
import { Easing, ReduceMotion } from "react-native-reanimated";
// Mirrors beUI's lib/ease.ts so native motion matches the web library.
export const EASE_OUT = Easing.bezier(0.16, 1, 0.3, 1);
export const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
stiffness: 500,
damping: 30,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Content swaps: label and icon slots trading places inside a control. */
export const SPRING_SWAP = {
stiffness: 460,
damping: 30,
mass: 0.55,
reduceMotion: ReduceMotion.System,
} as const;
/** Shared-layout glides: pills, indicators and panels moving between positions. */
export const SPRING_LAYOUT = {
stiffness: 360,
damping: 32,
mass: 0.6,
reduceMotion: ReduceMotion.System,
} as const;
/** Dragged handles and fills; critically damped so values never rebound off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
reduceMotion: ReduceMotion.System,
} as const;
export const motion = {
press: SPRING_PRESS,
swap: SPRING_SWAP,
layout: SPRING_LAYOUT,
glide: SPRING_GLIDE,
exit: { duration: 160, easing: EASE_OUT, reduceMotion: ReduceMotion.System },
fade: { duration: 150, reduceMotion: ReduceMotion.System },
} as const;
4Copy the component
export type {
SelectContentProps,
SelectItemProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from "./select";
export {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./select";
import {
createContext,
type ReactNode,
type RefObject,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
useWindowDimensions,
View,
type ViewProps,
} from "react-native";
import Animated, {
cancelAnimation,
interpolate,
ReduceMotion,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useResolveClassNames } from "uniwind";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import { DrawnPath, type PathSegment } from "../../primitives/drawn-path";
import { EASE_OUT } from "../../styles/motion";
// One progress value drives the surface, gap, corners and chevron, as on the
// web: a short, barely bouncy spring in, and a quicker ease back out.
const UNFOLD = {
duration: 300,
dampingRatio: 0.88,
reduceMotion: ReduceMotion.System,
};
const FOLD = { duration: 200, easing: EASE_OUT };
const FADE = { duration: 120 };
const ITEMS_IN = { duration: 120, easing: EASE_OUT };
const RADIUS = 12;
// How far the open panel sits from the trigger once it has pulled away.
const GAP = 8;
// The panel starts as a thin lip flush against the trigger's edge.
const LIP = 8;
// Space to keep between the panel and the window edge when choosing a side.
const EDGE = 16;
const MAX_HEIGHT = 320;
const CHEVRON: PathSegment[] = [
[6, 9, 12, 15],
[12, 15, 18, 9],
];
const CHECK: PathSegment[] = [
[5, 13, 9, 17],
[9, 17, 19, 7],
];
type Placement = "bottom" | "top";
type Anchor = { x: number; y: number; width: number; height: number };
type SelectContextValue = {
value: string | undefined;
open: boolean;
setOpen: (open: boolean) => void;
select: (value: string) => void;
labels: Map<string, string>;
register: (value: string, label: string) => void;
progress: SharedValue<number>;
reduce: boolean;
disabled: boolean;
triggerRef: RefObject<View | null>;
anchor: Anchor | null;
placement: Placement;
setPlacement: (placement: Placement) => void;
};
const SelectContext = createContext<SelectContextValue | null>(null);
// Items render twice: once hidden, only to register their labels for
// SelectValue, and once in the open panel.
const RegistrationContext = createContext(false);
function useSelect(component: string) {
const context = useContext(SelectContext);
if (!context) throw new Error(`${component} must be used within Select.`);
return context;
}
export type SelectProps = {
/** Controlled value. Omit it and use `defaultValue` for uncontrolled use. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Controlled open state. Omit it and use `defaultOpen` for uncontrolled use. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
/** Opens and closes without unfolding, independently of the system preference. */
disableAnimation?: boolean;
children: ReactNode;
};
export function Select({
value: valueProp,
defaultValue,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
disabled = false,
disableAnimation = false,
children,
}: SelectProps) {
const reducedMotion = useReducedMotionPreference();
const reduce = reducedMotion || disableAnimation;
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const value = valueProp ?? uncontrolledValue;
const open = openProp ?? uncontrolledOpen;
const [labels, setLabels] = useState<Map<string, string>>(new Map());
const [anchor, setAnchor] = useState<Anchor | null>(null);
const [placement, setPlacement] = useState<Placement>("bottom");
const triggerRef = useRef<View>(null);
const progress = useSharedValue(0);
const setOpen = useCallback(
(next: boolean) => {
if (next && disabled) return;
if (next) {
// Find the trigger on screen before the panel appears beside it.
triggerRef.current?.measureInWindow((x, y, width, height) => {
setAnchor({ x, y, width, height });
if (openProp === undefined) setUncontrolledOpen(true);
onOpenChange?.(true);
});
return;
}
if (openProp === undefined) setUncontrolledOpen(false);
onOpenChange?.(false);
},
[disabled, onOpenChange, openProp],
);
// A controlled open without a measured trigger still needs its anchor.
useEffect(() => {
if (!open || anchor) return;
triggerRef.current?.measureInWindow((x, y, width, height) =>
setAnchor({ x, y, width, height }),
);
}, [anchor, open]);
const select = useCallback(
(next: string) => {
if (valueProp === undefined) setUncontrolledValue(next);
onValueChange?.(next);
setOpen(false);
},
[onValueChange, setOpen, valueProp],
);
const register = useCallback((item: string, label: string) => {
setLabels((current) =>
current.get(item) === label ? current : new Map(current).set(item, label),
);
}, []);
const context = useMemo<SelectContextValue>(
() => ({
value,
open,
setOpen,
select,
labels,
register,
progress,
reduce,
disabled,
triggerRef,
anchor,
placement,
setPlacement,
}),
[
anchor,
disabled,
labels,
open,
placement,
progress,
reduce,
register,
select,
setOpen,
value,
],
);
return (
<SelectContext.Provider value={context}>{children}</SelectContext.Provider>
);
}
export type SelectTriggerProps = Omit<ViewProps, "children"> & {
children: ReactNode;
className?: string;
};
export function SelectTrigger({
children,
className = "",
accessibilityLabel,
accessibilityHint,
...props
}: SelectTriggerProps) {
const context = useSelect("SelectTrigger");
const { progress, reduce, placement, open, disabled } = context;
const { color: muted } = useResolveClassNames("text-muted");
const drawn = useSharedValue(1);
const label = context.labels.get(context.value ?? "");
// The edge facing the panel flattens while the panel is attached to it and
// rounds back as the panel pulls away, on the panel's own curve.
const shapeStyle = useAnimatedStyle(() => {
const near = reduce
? RADIUS
: interpolate(
progress.value,
[0, 0.2, 0.4, 1],
[RADIUS, 0, 0, RADIUS],
"clamp",
);
return placement === "top"
? {
borderTopLeftRadius: near,
borderTopRightRadius: near,
borderBottomLeftRadius: RADIUS,
borderBottomRightRadius: RADIUS,
}
: {
borderTopLeftRadius: RADIUS,
borderTopRightRadius: RADIUS,
borderBottomLeftRadius: near,
borderBottomRightRadius: near,
};
}, [placement, reduce]);
const chevronStyle = useAnimatedStyle(() => ({
transform: [
{
rotate: `${Math.min(1, Math.max(0, progress.value)) * 180}deg`,
},
],
}));
return (
<Animated.View
{...props}
ref={context.triggerRef}
collapsable={false}
className={`border border-border bg-background ${disabled ? "opacity-50" : ""} ${className}`}
style={[styles.trigger, shapeStyle, props.style]}
>
<Pressable
accessibilityRole="combobox"
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
accessibilityState={{ expanded: open, disabled }}
accessibilityValue={label ? { text: label } : undefined}
disabled={disabled}
onPress={() => context.setOpen(!open)}
className="min-h-11 flex-row items-center justify-between gap-2 px-3 py-2"
>
<View className="flex-1">{children}</View>
<Animated.View style={[styles.chevron, chevronStyle]}>
<DrawnPath
segments={CHEVRON}
size={16}
strokeWidth={2}
color={muted}
progress={drawn}
/>
</Animated.View>
</Pressable>
</Animated.View>
);
}
export type SelectValueProps = {
placeholder?: string;
className?: string;
};
export function SelectValue({
placeholder = "Select",
className = "",
}: SelectValueProps) {
const context = useSelect("SelectValue");
const label =
context.value === undefined ? undefined : context.labels.get(context.value);
return (
<Text
numberOfLines={1}
className={`text-sm ${label ? "text-foreground" : "text-muted"} ${className}`}
>
{label ?? placeholder}
</Text>
);
}
export type SelectContentProps = {
children: ReactNode;
className?: string;
};
export function SelectContent({
children,
className = "",
}: SelectContentProps) {
const context = useSelect("SelectContent");
const { open, progress, reduce, anchor, placement, setPlacement } = context;
const window = useWindowDimensions();
const [height, setHeight] = useState(0);
// The modal stays up while the panel folds away.
const [present, setPresent] = useState(open);
const itemsIn = useSharedValue(open ? 1 : 0);
if (open && !present) setPresent(true);
const hide = useCallback(() => setPresent(false), []);
useEffect(() => {
if (!present) return;
cancelAnimation(progress);
if (open) {
progress.value = reduce ? withTiming(1, FADE) : withSpring(1, UNFOLD);
itemsIn.value = reduce ? 1 : withTiming(1, ITEMS_IN);
} else {
itemsIn.value = withTiming(0, { duration: 80 });
progress.value = withTiming(0, reduce ? FADE : FOLD, (done) => {
if (done) scheduleOnRN(hide);
});
}
}, [hide, itemsIn, open, present, progress, reduce]);
// Choose a side once the panel's height is known: below unless it doesn't
// fit there and there is more room above.
useLayoutEffect(() => {
if (!open || !anchor || height === 0) return;
const below = window.height - (anchor.y + anchor.height);
const above = anchor.y;
setPlacement(
below < height + GAP + EDGE && above > below ? "top" : "bottom",
);
}, [anchor, height, open, setPlacement, window.height]);
const panelHeight = Math.min(height, MAX_HEIGHT);
const isTop = placement === "top";
const ready = panelHeight > 0 && !reduce;
const panelStyle = useAnimatedStyle(() => {
const p = progress.value;
const reveal = Math.min(1, Math.max(0, p));
const detach = interpolate(p, [0.4, 1], [0, 1], "clamp");
const near = ready ? RADIUS * detach : RADIUS;
// Reveal a full-size panel from its near edge without scaling its rows.
const visible = ready ? LIP + (panelHeight - LIP) * reveal : panelHeight;
return {
height: visible,
opacity: interpolate(p, reduce ? [0, 1] : [0, 0.12], [0, 1], "clamp"),
transform: [
{ translateY: ready ? (isTop ? GAP : -GAP) * (1 - detach) : 0 },
],
borderTopLeftRadius: isTop ? RADIUS : near,
borderTopRightRadius: isTop ? RADIUS : near,
borderBottomLeftRadius: isTop ? near : RADIUS,
borderBottomRightRadius: isTop ? near : RADIUS,
};
}, [isTop, panelHeight, ready, reduce]);
const itemsStyle = useAnimatedStyle(
() => ({
opacity: itemsIn.value,
transform: [{ translateY: reduce ? 0 : -4 * (1 - itemsIn.value) }],
}),
[reduce],
);
const position =
anchor === null
? null
: isTop
? {
left: anchor.x,
width: anchor.width,
bottom: window.height - anchor.y + GAP,
}
: {
left: anchor.x,
width: anchor.width,
top: anchor.y + anchor.height + GAP,
};
return (
<>
{/* Hidden copy so SelectValue knows every label before the first open. */}
<View style={styles.registry} accessibilityElementsHidden>
<RegistrationContext.Provider value={true}>
{children}
</RegistrationContext.Provider>
</View>
<Modal
transparent
visible={present}
animationType="none"
statusBarTranslucent
navigationBarTranslucent
onRequestClose={() => context.setOpen(false)}
>
<Pressable
accessibilityLabel="Close"
accessibilityRole="button"
style={StyleSheet.absoluteFill}
onPress={() => context.setOpen(false)}
/>
{position ? (
<Animated.View
accessibilityViewIsModal
className={`overflow-hidden border border-border bg-background ${className}`}
style={[styles.panel, position, panelStyle]}
>
{/* Pinned to the near edge, so the reveal uncovers it from there. */}
<View
style={[
styles.content,
isTop ? styles.pinBottom : styles.pinTop,
height > MAX_HEIGHT && { height: MAX_HEIGHT },
]}
>
<ScrollView
scrollEnabled={height > MAX_HEIGHT}
bounces={false}
onContentSizeChange={(_, contentHeight) => {
// Content plus the panel's 1-point borders.
setHeight(contentHeight + 2);
}}
>
<Animated.View style={[styles.items, itemsStyle]}>
{children}
</Animated.View>
</ScrollView>
</View>
</Animated.View>
) : null}
</Modal>
</>
);
}
export type SelectItemProps = {
value: string;
/** Text for SelectValue and screen readers when children aren't a string. */
label?: string;
disabled?: boolean;
className?: string;
children: ReactNode;
};
export function SelectItem({
value,
label: labelProp,
disabled = false,
className = "",
children,
}: SelectItemProps) {
const context = useSelect("SelectItem");
const registering = useContext(RegistrationContext);
const { color: foreground } = useResolveClassNames("text-foreground");
const drawn = useSharedValue(1);
const selected = context.value === value;
const label = labelProp ?? (typeof children === "string" ? children : value);
const { register } = context;
useLayoutEffect(() => {
if (registering) register(value, label);
}, [label, register, registering, value]);
if (registering) return null;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ selected, disabled }}
disabled={disabled}
onPress={() => context.select(value)}
>
{({ pressed }) => (
<View
className={`min-h-10 flex-row items-center justify-between gap-2 rounded-lg px-2.5 py-2 ${selected || pressed ? "bg-surface" : ""} ${disabled ? "opacity-50" : ""} ${className}`}
>
{typeof children === "string" ? (
<Text
className={`flex-1 text-sm ${selected ? "text-foreground" : "text-muted"}`}
>
{children}
</Text>
) : (
<View className="flex-1">{children}</View>
)}
{selected ? (
<DrawnPath
segments={CHECK}
size={14}
strokeWidth={2.5}
color={foreground}
progress={drawn}
/>
) : null}
</View>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
trigger: { alignSelf: "stretch" },
chevron: {
width: 20,
height: 20,
alignItems: "center",
justifyContent: "center",
},
panel: {
position: "absolute",
boxShadow:
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)",
},
content: { position: "absolute", left: 0, right: 0 },
pinTop: { top: 0 },
pinBottom: { bottom: 0 },
items: { padding: 4 },
registry: { display: "none" },
});
Documentation
Native port of beUI Select. A panel that unfolds out of its trigger: it starts as a thin lip on the trigger's edge, grows to full height, then pulls 8 points away into its own card while the trigger's near corners square off and round back.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./src";
<Select defaultValue="expo" onValueChange={setFramework}>
<SelectTrigger accessibilityLabel="Framework">
<SelectValue placeholder="Choose a framework" />
</SelectTrigger>
<SelectContent>
<SelectItem value="expo">Expo</SelectItem>
<SelectItem value="next">Next.js</SelectItem>
<SelectItem value="legacy" disabled>Legacy</SelectItem>
</SelectContent>
</Select>Parts
- Select: owns the state.
value/defaultValue/onValueChangefor the selection,open/defaultOpen/onOpenChangefor the panel,disabledfor the whole control. - SelectTrigger: the field. It's a combobox for screen readers and reports whether it's expanded. Its chevron turns as the panel opens.
- SelectValue: the chosen item's label, or
placeholderin the muted color. - SelectContent: the panel. It renders in a transparent modal at the trigger's position, so it draws over everything and isn't clipped by scroll views. A tap outside it, or Android's back button, closes it.
- SelectItem: an option. The selected one gets a drawn check. When its children aren't a plain string, give it a
labelfor SelectValue and screen readers.
Placement and long lists
The panel opens below the trigger, or above it when there isn't room below and there's more above. It's capped at 320 points and scrolls past that.
Motion
Opening uses a short, barely bouncy spring (300 ms, damping ratio 0.88). Closing folds back over 200 ms with beUI's ease-out. Items fade in as the panel opens. With reduced motion, or disableAnimation, the panel appears and disappears without unfolding.
Copy this directory, primitives/drawn-path.tsx, hooks/use-reduced-motion.ts, styles/motion.ts, and the semantic tokens in styles/theme.css.