AniUI

Expandable View

Generic expand/collapse container with spring-driven width/height/corner-radius morphing and a slot API.

Web preview — components render natively on iOS & Android
import {
  ExpandableView,
  ExpandableViewCollapsed,
  ExpandableViewExpanded,
  ExpandableViewClose,
} from "@/components/ui/expandable-view";
import { Text } from "react-native";

<ExpandableView>
  <ExpandableViewCollapsed>
    <Text className="text-sm font-medium text-foreground">Tap to expand</Text>
  </ExpandableViewCollapsed>
  <ExpandableViewExpanded>
    <Text className="p-4 text-sm text-foreground">Expanded content goes here.</Text>
    <ExpandableViewClose />
  </ExpandableViewExpanded>
</ExpandableView>

Installation#

npx @aniui/cli add expandable-view

Usage#

import {
  ExpandableView,
  ExpandableViewCollapsed,
  ExpandableViewExpanded,
  ExpandableViewClose,
} from "@/components/ui/expandable-view";
import { Text } from "react-native";

<ExpandableView>
  <ExpandableViewCollapsed>
    <Text className="text-sm font-medium text-foreground">Tap to expand</Text>
  </ExpandableViewCollapsed>
  <ExpandableViewExpanded>
    <Text className="p-4 text-sm text-foreground">Expanded content goes here.</Text>
    <ExpandableViewClose />
  </ExpandableViewExpanded>
</ExpandableView>

Source#

components/ui/expandable-view.tsx
import React, {
  Children,
  createContext,
  isValidElement,
  useCallback,
  useContext,
  useMemo,
  useState,
} from "react";
import { Pressable } from "react-native";
import Animated, {
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  type SharedValue,
  type WithSpringConfig,
} from "react-native-reanimated";
import { X } from "lucide-react-native";
import { cn } from "@/lib/utils";
import { useThemeColors } from "@/components/ui/theme-provider";

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);

const PRESS_SPRING: WithSpringConfig = { damping: 12, stiffness: 250, mass: 0.5 };
const EXPAND_SPRING: WithSpringConfig = { damping: 13, stiffness: 120, mass: 0.5 };

interface ExpandableViewContextValue {
  progress: SharedValue<number>;
  expanded: boolean;
  expand: () => void;
  collapse: () => void;
}

const ExpandableViewContext = createContext<ExpandableViewContextValue | null>(null);

function useExpandableView(component: string) {
  const ctx = useContext(ExpandableViewContext);
  if (!ctx) throw new Error(`${component} must be rendered inside <ExpandableView>.`);
  return ctx;
}

export interface ExpandableViewSlotProps {
  children?: React.ReactNode;
}

// Slot markers — never rendered directly. ExpandableView pulls each one's
// children out via Children.forEach and puts them in their own absolutely
// positioned animated layer, so the collapsed pill and the expanded panel can
// cross-fade over each other instead of one replacing the other in the tree.
type SlotRole = "collapsed" | "expanded";
interface SlotComponent {
  (props: ExpandableViewSlotProps): null;
  role?: SlotRole;
}

export const ExpandableViewCollapsed: SlotComponent = () => null;
ExpandableViewCollapsed.role = "collapsed";

export const ExpandableViewExpanded: SlotComponent = () => null;
ExpandableViewExpanded.role = "expanded";

export interface ExpandableViewProps extends React.ComponentPropsWithoutRef<typeof Animated.View> {
  children: React.ReactNode;
  className?: string;
  collapsedWidth?: number;
  expandedWidth?: number;
  collapsedHeight?: number;
  expandedHeight?: number;
  collapsedRadius?: number;
  expandedRadius?: number;
  pressSpring?: WithSpringConfig;
  expandSpring?: WithSpringConfig;
  onExpandedChange?: (expanded: boolean) => void;
}

/**
 * Generic expand/collapse container: a collapsed pill morphs (width, height,
 * corner radius, all spring-driven) into an expanded panel. Reusable
 * primitive — screens compose their own collapsed/expanded content via the
 * `Collapsed`/`Expanded` slots and an optional `Close` button.
 */
export function ExpandableView({
  children,
  className,
  collapsedWidth = 170,
  expandedWidth = 330,
  collapsedHeight = 50,
  expandedHeight = 350,
  collapsedRadius = 99,
  expandedRadius = 30,
  pressSpring = PRESS_SPRING,
  expandSpring = EXPAND_SPRING,
  onExpandedChange,
  style,
  ...props
}: ExpandableViewProps) {
  const progress = useSharedValue(0);
  const scale = useSharedValue(1);
  const [expanded, setExpanded] = useState(false);

  const { collapsedNode, expandedNode } = useMemo(() => {
    let collapsedNode: React.ReactNode = null;
    let expandedNode: React.ReactNode = null;
    Children.forEach(children, (child) => {
      if (!isValidElement(child)) return;
      const role = (child.type as SlotComponent).role;
      const slotProps = child.props as ExpandableViewSlotProps;
      if (role === "collapsed") collapsedNode = slotProps.children;
      else if (role === "expanded") expandedNode = slotProps.children;
    });
    return { collapsedNode, expandedNode };
  }, [children]);

  const expand = useCallback(() => {
    setExpanded(true);
    onExpandedChange?.(true);
    scale.value = withSpring(1, pressSpring);
    progress.value = withSpring(1, expandSpring);
  }, [onExpandedChange, scale, progress, pressSpring, expandSpring]);

  const collapse = useCallback(() => {
    setExpanded(false);
    onExpandedChange?.(false);
    progress.value = withSpring(0, expandSpring);
  }, [onExpandedChange, progress, expandSpring]);

  const ctx = useMemo(
    () => ({ progress, expanded, expand, collapse }),
    [progress, expanded, expand, collapse]
  );

  const containerStyle = useAnimatedStyle(() => ({
    width: interpolate(progress.value, [0, 1], [collapsedWidth, expandedWidth]),
    height: interpolate(progress.value, [0, 1], [collapsedHeight, expandedHeight]),
    borderRadius: interpolate(progress.value, [0, 1], [collapsedRadius, expandedRadius]),
    transform: [{ scale: scale.value }],
  }));

  const collapsedStyle = useAnimatedStyle(() => ({
    opacity: interpolate(progress.value, [0, 0.5], [1, 0]),
    transform: [{ translateY: interpolate(progress.value, [0, 1], [0, 60]) }],
  }));

  const expandedStyle = useAnimatedStyle(() => ({
    opacity: interpolate(progress.value, [0.15, 1], [0, 1]),
  }));

  return (
    <ExpandableViewContext.Provider value={ctx}>
      <Animated.View
        className={cn("items-center justify-center overflow-hidden bg-muted", className)}
        style={[containerStyle, style]}
        {...props}
      >
        <Animated.View
          className="absolute"
          style={[expandedStyle, { width: expandedWidth, height: expandedHeight }]}
          pointerEvents={expanded ? "auto" : "none"}
        >
          {expandedNode}
        </Animated.View>

        <AnimatedPressable
          className="absolute inset-0 min-h-12 min-w-12 flex-row items-center justify-center gap-2"
          style={collapsedStyle}
          pointerEvents={expanded ? "none" : "auto"}
          onPress={expand}
          onPressIn={() => {
            if (!expanded) scale.value = withSpring(1.06, pressSpring);
          }}
          onPressOut={() => {
            scale.value = withSpring(1, pressSpring);
          }}
          accessible={true}
          accessibilityRole="button"
          accessibilityState={{ expanded }}
        >
          {collapsedNode}
        </AnimatedPressable>
      </Animated.View>
    </ExpandableViewContext.Provider>
  );
}

export interface ExpandableViewCloseProps {
  children?: React.ReactNode;
  className?: string;
}

/** Fades in once the panel is mostly expanded; collapses the view on press. */
export function ExpandableViewClose({ children, className }: ExpandableViewCloseProps) {
  const { progress, collapse } = useExpandableView("ExpandableViewClose");
  const colors = useThemeColors();

  const style = useAnimatedStyle(() => ({
    opacity: interpolate(progress.value, [0.6, 1], [0, 1]),
  }));

  return (
    <Animated.View className="absolute right-2 top-2 z-10" style={style}>
      <Pressable
        className={cn(
          "min-h-12 min-w-12 items-center justify-center rounded-full bg-background",
          className
        )}
        onPress={collapse}
        hitSlop={8}
        accessible={true}
        accessibilityRole="button"
        accessibilityLabel="Collapse"
      >
        {children ?? <X size={18} color={colors.foreground} strokeWidth={2.5} />}
      </Pressable>
    </Animated.View>
  );
}