Expandable Control
React NativeButtons and chips that spring open to reveal more.
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 {
type ComponentType,
createElement,
isValidElement,
type ReactNode,
} from "react";
import type { ColorValue } from "react-native";
export type IconProps = { size: number; color: ColorValue | undefined };
/** A component receiving `{ size, color }` (such as a lucide icon), or a prebuilt node. */
export type IconSource = ComponentType<IconProps> | ReactNode;
export function renderIcon(icon: IconSource, props: IconProps) {
if (typeof icon === "function") return createElement(icon, props);
// forwardRef and memo components (lucide-react-native, for example) are objects.
if (
icon != null &&
typeof icon === "object" &&
!isValidElement(icon) &&
"$$typeof" in icon
) {
return createElement(icon as unknown as ComponentType<IconProps>, props);
}
return icon;
}
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
import { type Ref, useCallback, useEffect, useState } from "react";
import {
type GestureResponderEvent,
Pressable,
type PressableProps,
StyleSheet,
Text,
View,
} from "react-native";
import Animated, {
Extrapolation,
interpolate,
interpolateColor,
ReduceMotion,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { useResolveClassNames } from "uniwind";
import { useReducedMotionPreference } from "../../hooks/use-reduced-motion";
import { DrawnPath, type PathSegment } from "../../primitives/drawn-path";
import { type IconSource, renderIcon } from "../../primitives/render-icon";
import { SPRING_PRESS } from "../../styles/motion";
// One spring drives everything: a 0 to 1 progress that the width, the
// revealed content's fade and slide, and the surface's tint all read from.
// Folding is opening played backward, overshoot included, and a tap
// mid-motion reverses from wherever it is. Tuned quicker than the web
// version's continuity spring, like a native control under a finger, with a
// hint of give at the end.
const CONTINUITY = {
stiffness: 320,
damping: 22,
mass: 0.8,
reduceMotion: ReduceMotion.System,
};
const PRESS_SCALE = 0.96;
const BUTTON_HEIGHT = 44;
const BUTTON_PAD = 4;
// How narrow the outline may dip while a fold overshoots past closed.
const MIN_WIDTH = 30;
const ICON_SLOT = 36;
// The label's trailing space inside the open pill.
const LABEL_END = 12;
// How far revealed content slides as it appears.
const SLIDE = 8;
const CHIP_HEIGHT = 40;
const ACTION_WIDTH = 32;
const CHIP_PAD = 12;
// The label's trailing padding once the action has made room.
const CHIP_PAD_OPEN = 4;
const CLOSE: PathSegment[] = [
[18, 6, 6, 18],
[6, 6, 18, 18],
];
type ExpandableState = {
/** Controlled state. Omit it and use `defaultExpanded` for uncontrolled use. */
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
};
function useExpandableState({
expanded,
defaultExpanded = false,
onExpandedChange,
}: ExpandableState) {
const [internal, setInternal] = useState(defaultExpanded);
const controlled = expanded !== undefined;
const current = expanded ?? internal;
const setExpanded = useCallback(
(next: boolean) => {
if (!controlled) setInternal(next);
onExpandedChange?.(next);
},
[controlled, onExpandedChange],
);
return [current, setExpanded] as const;
}
/** The control's one progress value: springs to 1 open and 0 folded. */
function useProgress(open: boolean, reduce: boolean) {
const progress = useSharedValue(open ? 1 : 0);
useEffect(() => {
const target = open ? 1 : 0;
progress.value = reduce ? target : withSpring(target, CONTINUITY);
}, [open, progress, reduce]);
return progress;
}
/**
* Fade and slide for revealed content, read from the shared progress, with an
* optional quarter turn in for icons.
*/
function useRevealStyle(progress: SharedValue<number>, turn = 0) {
return useAnimatedStyle(() => {
const p = progress.value;
return {
// Content appears over the back part of the way open, so it's never
// seen squeezed in a surface still too narrow for it.
opacity: interpolate(p, [0.3, 0.9], [0, 1], Extrapolation.CLAMP),
transform: [
{
translateX: interpolate(p, [0, 1], [-SLIDE, 0], Extrapolation.CLAMP),
},
{ rotate: `${turn * (1 - Math.min(1, Math.max(0, p)))}deg` },
],
};
});
}
/**
* The surface tints and its outline firms up as it opens, so an open control
* reads as on at a glance. Both follow the same progress as the motion.
*/
function useSurfaceStyle(progress: SharedValue<number>) {
const { backgroundColor: tint } = useResolveClassNames("bg-foreground/5");
const rest = useResolveClassNames("border-border");
const strong = useResolveClassNames("border-border-strong");
// On web, borders resolve per side.
const from = String(rest.borderColor ?? rest.borderTopColor ?? "transparent");
const to = String(
strong.borderColor ?? strong.borderTopColor ?? "transparent",
);
const fill = String(tint ?? "transparent");
return useAnimatedStyle(() => {
const p = Math.min(1, Math.max(0, progress.value));
return {
backgroundColor: interpolateColor(p, [0, 1], ["transparent", fill]),
borderColor: interpolateColor(p, [0, 1], [from, to]),
};
});
}
/** Sinks a pressable surface slightly while a finger is on it. */
function usePress(enabled: boolean) {
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return {
style,
onPressIn: () => {
if (enabled) scale.value = withSpring(PRESS_SCALE, SPRING_PRESS);
},
onPressOut: () => {
if (enabled) scale.value = withSpring(1, SPRING_PRESS);
},
};
}
export type ExpandableButtonProps = Omit<PressableProps, "children" | "style"> &
ExpandableState & {
/** A component given `{ size, color }`, or a prebuilt element. */
icon: IconSource;
label: string;
/** Moves without springing, independently of the system preference. */
disableAnimation?: boolean;
className?: string;
labelClassName?: string;
ref?: Ref<View>;
};
/**
* A round icon button that springs open to reveal its label when pressed, and
* folds back the same way on the next press.
*/
export function ExpandableButton({
icon,
label,
expanded,
defaultExpanded,
onExpandedChange,
onPress,
onPressIn,
onPressOut,
disabled,
disableAnimation = false,
className = "",
labelClassName = "",
accessibilityLabel,
ref,
...props
}: ExpandableButtonProps) {
const reducedMotion = useReducedMotionPreference();
const reduce = reducedMotion || disableAnimation;
const [open, setOpen] = useExpandableState({
expanded,
defaultExpanded,
onExpandedChange,
});
const { color: foreground } = useResolveClassNames("text-foreground");
const [labelWidth, setLabelWidth] = useState(0);
const press = usePress(!reduce && !disabled);
const progress = useProgress(open, reduce);
const revealStyle = useRevealStyle(progress);
const surfaceStyle = useSurfaceStyle(progress);
// The pill grows by the label's width. Past closed, a fold's overshoot dips
// the outline a few points narrower, mirroring the overshoot past open,
// while the icon below stays centered and full size.
const pillStyle = useAnimatedStyle(() => ({
width: Math.max(MIN_WIDTH, BUTTON_HEIGHT + labelWidth * progress.value),
}));
const iconStyle = useAnimatedStyle(() => {
const width = Math.max(
MIN_WIDTH,
BUTTON_HEIGHT + labelWidth * progress.value,
);
return {
transform: [{ translateX: Math.min(0, width - BUTTON_HEIGHT) / 2 }],
};
});
const labelClasses = `text-sm font-medium text-foreground ${labelClassName}`;
return (
<Animated.View style={[styles.hug, press.style]}>
<Pressable
{...props}
ref={ref}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
aria-expanded={open}
aria-disabled={disabled ?? false}
disabled={disabled}
onPress={(event: GestureResponderEvent) => {
onPress?.(event);
if (!event.defaultPrevented) setOpen(!open);
}}
onPressIn={(event) => {
press.onPressIn();
onPressIn?.(event);
}}
onPressOut={(event) => {
press.onPressOut();
onPressOut?.(event);
}}
>
<Animated.View
className={`flex-row items-center overflow-hidden rounded-full border border-border ${disabled ? "opacity-50" : ""} ${className}`}
style={[styles.button, pillStyle, surfaceStyle]}
>
<Animated.View style={[styles.iconSlot, iconStyle]}>
{renderIcon(icon, { size: 16, color: foreground as string })}
</Animated.View>
<Animated.View style={[styles.label, revealStyle]}>
<Text numberOfLines={1} className={labelClasses}>
{label}
</Text>
</Animated.View>
</Animated.View>
</Pressable>
{/* Measured at its natural width, outside the clipped pill. */}
<View pointerEvents="none" style={styles.measure}>
<Text
numberOfLines={1}
className={labelClasses}
onLayout={({ nativeEvent }) =>
setLabelWidth(Math.ceil(nativeEvent.layout.width) + LABEL_END)
}
>
{label}
</Text>
</View>
</Animated.View>
);
}
export type ExpandableChipProps = ExpandableState & {
label: string;
/** Replaces the X. A component given `{ size, color }`, or an element. */
actionIcon?: IconSource;
/** Names the action for screen readers, such as "Remove React". */
actionLabel: string;
onAction?: () => void;
/** Folds the chip after its action runs. Defaults to true. */
collapseOnAction?: boolean;
disabled?: boolean;
/** Moves without springing, independently of the system preference. */
disableAnimation?: boolean;
className?: string;
labelClassName?: string;
};
/**
* A chip that springs open to reveal a trailing action, such as remove, when
* its label is pressed, and folds back the same way.
*/
export function ExpandableChip({
label,
actionIcon,
actionLabel,
onAction,
collapseOnAction = true,
expanded,
defaultExpanded,
onExpandedChange,
disabled = false,
disableAnimation = false,
className = "",
labelClassName = "",
}: ExpandableChipProps) {
const reducedMotion = useReducedMotionPreference();
const reduce = reducedMotion || disableAnimation;
const [open, setOpen] = useExpandableState({
expanded,
defaultExpanded,
onExpandedChange,
});
const { color: muted } = useResolveClassNames("text-muted");
const drawn = useSharedValue(1);
const press = usePress(!reduce && !disabled);
const progress = useProgress(open, reduce);
// The X turns in as it appears.
const revealStyle = useRevealStyle(progress, -90);
const surfaceStyle = useSurfaceStyle(progress);
// Opening, the action slot grows while the label's trailing padding gives
// some of its room back, so the chip widens by their difference. Past
// closed, the padding alone takes up the fold's overshoot, narrowing the
// chip by as much as opening's overshoot widens it.
const slotStyle = useAnimatedStyle(() => ({
width: Math.max(0, ACTION_WIDTH * progress.value),
}));
const labelStyle = useAnimatedStyle(() => {
const p = progress.value;
const grow = ACTION_WIDTH - (CHIP_PAD - CHIP_PAD_OPEN);
return {
paddingRight:
p >= 0
? CHIP_PAD - (CHIP_PAD - CHIP_PAD_OPEN) * p
: CHIP_PAD + grow * p,
};
});
return (
<Animated.View
className={`flex-row items-center overflow-hidden rounded-full border border-border ${disabled ? "opacity-50" : ""} ${className}`}
style={[styles.chip, styles.hug, press.style, surfaceStyle]}
>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
aria-expanded={open}
aria-disabled={disabled}
disabled={disabled}
onPress={() => setOpen(!open)}
onPressIn={press.onPressIn}
onPressOut={press.onPressOut}
>
<Animated.View style={[styles.chipLabel, labelStyle]}>
<Text
numberOfLines={1}
className={`text-sm font-medium text-foreground ${labelClassName}`}
>
{label}
</Text>
</Animated.View>
</Pressable>
<Animated.View
style={[styles.actionSlot, slotStyle]}
accessibilityElementsHidden={!open}
importantForAccessibility={open ? "auto" : "no-hide-descendants"}
>
<Animated.View style={revealStyle}>
<Pressable
accessibilityRole="button"
accessibilityLabel={actionLabel}
disabled={disabled || !open}
hitSlop={6}
onPress={() => {
onAction?.();
if (collapseOnAction) setOpen(false);
}}
className="h-10 w-8 items-center justify-center rounded-full active:opacity-60"
>
{actionIcon != null ? (
renderIcon(actionIcon, { size: 14, color: muted as string })
) : (
<DrawnPath
segments={CLOSE}
size={14}
strokeWidth={2}
color={muted}
progress={drawn}
/>
)}
</Pressable>
</Animated.View>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
hug: { alignSelf: "flex-start" },
button: { height: BUTTON_HEIGHT, padding: BUTTON_PAD - 1 },
iconSlot: {
width: ICON_SLOT,
height: ICON_SLOT,
alignItems: "center",
justifyContent: "center",
},
// Never squeezed while the pill grows; the pill clips it instead.
label: { paddingRight: LABEL_END, flexShrink: 0 },
measure: { position: "absolute", opacity: 0, left: 0, top: 0 },
chip: { height: CHIP_HEIGHT },
chipLabel: {
height: CHIP_HEIGHT - 2,
justifyContent: "center",
paddingLeft: CHIP_PAD,
},
actionSlot: { height: CHIP_HEIGHT - 2, overflow: "hidden" },
});
export type {
ExpandableButtonProps,
ExpandableChipProps,
} from "./expandable-control";
export { ExpandableButton, ExpandableChip } from "./expandable-control";
Documentation
Native port of beUI Expandable Control. A round icon button and a chip that spring open when pressed, revealing a label or a trailing action, and fold back on the next press.
import { ExpandableButton, ExpandableChip } from "./src";
<ExpandableButton icon={BellIcon} label="Notifications" />
<ExpandableChip
label="React"
actionLabel="Remove React"
onAction={() => removeTag("react")}
/>ExpandableButton
A 44-point round button with an icon that grows into a pill showing its label.
icon: a component given{ size, color }, or an element.label: the revealed text, also its accessible name.expanded/defaultExpanded/onExpandedChange: controlled or uncontrolled. Control it to keep one button in a group open at a time.onPressruns first; callevent.preventDefault()in it to keep the button from toggling.disabled,disableAnimation,className,labelClassName, plus Pressable props.
ExpandableChip
A 40-point chip whose label, when pressed, makes room for a trailing action.
label,actionLabel(the action's accessible name, such as "Remove React"),onAction.actionIcon: replaces the default X.collapseOnAction: folds the chip after the action runs. Defaults to true.expanded/defaultExpanded/onExpandedChange,disabled,disableAnimation,className,labelClassName.
Motion
One spring drives each control: a 0 to 1 progress (stiffness 320, damping 22, mass 0.8), quicker than the web version's continuity spring, like a native control under a finger, with a hint of give at the end. The width, the revealed content's fade and 8-point slide, and the surface's tint all read from it, so folding is opening played backward, overshoot included, and a tap mid-motion reverses from wherever it is.
- The button grows by its label's measured width. As a fold overshoots past closed, its outline dips a few points narrower while the icon stays centered and full size.
- The chip's action slot opens as its label's trailing padding gives room back, and its X turns in a quarter turn as it appears.
- Open controls take a soft tint and a firmer outline, so they read as on at a glance.
- Pressing sinks either to 96% on beUI's press spring.
The web version blurs content as it appears; React Native has no cheap blur, so it fades and slides instead. With reduced motion, or disableAnimation, controls change at once and the press doesn't scale.
For haptics, call your haptics library from onExpandedChange, as the showcase does with expo-haptics. To animate a chip out of a list when its action removes it, wrap it in an Animated.View with an exiting animation and a layout transition, and pass collapseOnAction={false}.
Accessibility
The button and the chip's label report whether they're expanded. The chip's action is hidden from screen readers and can't be pressed while the chip is folded.
Copy this directory, primitives/drawn-path.tsx, primitives/render-icon.tsx, hooks/use-reduced-motion.ts, styles/motion.ts, and the semantic tokens in styles/theme.css.