AniUI

Action Rail

Expandable icon toolbar that reveals labeled actions, with its own light/dark palette system.

Web preview — components render natively on iOS & Android
import {
  ActionRail,
  ActionRailGroup,
  ActionRailAction,
  ActionRailIcon,
  ActionRailLabel,
  ActionRailOverflow,
  ActionRailTrigger,
} from "@/components/ui/action-rail";
import { Heart, Share2, Bookmark } from "lucide-react-native";

<ActionRail onAction={(value) => console.log(value)}>
  <ActionRailGroup>
    <ActionRailAction value="like">
      <ActionRailIcon>{({ color, size }) => <Heart color={color} size={size} />}</ActionRailIcon>
      <ActionRailLabel>Like</ActionRailLabel>
    </ActionRailAction>
  </ActionRailGroup>
  <ActionRailOverflow>
    <ActionRailAction value="share">
      <ActionRailIcon>{({ color, size }) => <Share2 color={color} size={size} />}</ActionRailIcon>
      <ActionRailLabel>Share</ActionRailLabel>
    </ActionRailAction>
    <ActionRailAction value="save">
      <ActionRailIcon>{({ color, size }) => <Bookmark color={color} size={size} />}</ActionRailIcon>
      <ActionRailLabel>Save</ActionRailLabel>
    </ActionRailAction>
  </ActionRailOverflow>
  <ActionRailTrigger />
</ActionRail>

Installation#

npx @aniui/cli add action-rail

Usage#

import {
  ActionRail,
  ActionRailGroup,
  ActionRailAction,
  ActionRailIcon,
  ActionRailLabel,
  ActionRailOverflow,
  ActionRailTrigger,
} from "@/components/ui/action-rail";
import { Heart, Share2, Bookmark } from "lucide-react-native";

<ActionRail onAction={(value) => console.log(value)}>
  <ActionRailGroup>
    <ActionRailAction value="like">
      <ActionRailIcon>{({ color, size }) => <Heart color={color} size={size} />}</ActionRailIcon>
      <ActionRailLabel>Like</ActionRailLabel>
    </ActionRailAction>
  </ActionRailGroup>
  <ActionRailOverflow>
    <ActionRailAction value="share">
      <ActionRailIcon>{({ color, size }) => <Share2 color={color} size={size} />}</ActionRailIcon>
      <ActionRailLabel>Share</ActionRailLabel>
    </ActionRailAction>
    <ActionRailAction value="save">
      <ActionRailIcon>{({ color, size }) => <Bookmark color={color} size={size} />}</ActionRailIcon>
      <ActionRailLabel>Save</ActionRailLabel>
    </ActionRailAction>
  </ActionRailOverflow>
  <ActionRailTrigger />
</ActionRail>

Source#

components/ui/action-rail.tsx
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Pressable, Text, useColorScheme, View } from "react-native";
import Animated, {
  FadeIn,
  FadeOut,
  LinearTransition,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  type SharedValue,
  type WithSpringConfig,
} from "react-native-reanimated";
import { Ellipsis, X } from "lucide-react-native";
import { cn } from "@/lib/utils";

export type ActionRailSize = "sm" | "md";
export type ActionRailTheme = "light" | "dark";

export interface ActionRailPalette {
  track: string;
  border: string;
  action: string;
  actionText: string;
  toggle: string;
  toggleText: string;
}

interface ActionRailMetrics {
  trackPadding: number;
  gap: number;
  actionHeight: number;
  actionPaddingX: number;
  actionGap: number;
  toggleSize: number;
  iconSize: number;
  fontSize: number;
}

const SHELL_SPRING: WithSpringConfig = { damping: 13, stiffness: 150, mass: 0.5 };
const PRESS_SPRING: WithSpringConfig = { damping: 22, stiffness: 420, mass: 0.6 };
const PRESS_SCALE = 0.95;
const GLYPH_SPRING: WithSpringConfig = { damping: 16, stiffness: 220, mass: 0.6 };
const GLYPH_MIN_SCALE = 0.55;

const SIZES: Record<ActionRailSize, ActionRailMetrics> = {
  sm: { trackPadding: 4, gap: 4, actionHeight: 32, actionPaddingX: 12, actionGap: 6, toggleSize: 32, iconSize: 14, fontSize: 12 },
  md: { trackPadding: 6, gap: 6, actionHeight: 36, actionPaddingX: 14, actionGap: 8, toggleSize: 36, iconSize: 16, fontSize: 14 },
};

const LIGHT_PALETTE: ActionRailPalette = {
  track: "#ffffff", border: "#e3e7ec", action: "#f5f7fa", actionText: "#111111",
  toggle: "#111111", toggleText: "#ffffff",
};
const DARK_PALETTE: ActionRailPalette = {
  track: "#171716", border: "#2b2a25", action: "#232320", actionText: "#f6f3ec",
  toggle: "#f6f3ec", toggleText: "#111111",
};
const PALETTES: Record<ActionRailTheme, ActionRailPalette> = { light: LIGHT_PALETTE, dark: DARK_PALETTE };

// Shared layout transition so the track resizes (as actions or the overflow
// group mount/unmount) with the same spring feel as the press interactions,
// instead of Reanimated's default linear layout animation.
function shellLayout() {
  return LinearTransition.springify()
    .damping(SHELL_SPRING.damping as number)
    .stiffness(SHELL_SPRING.stiffness as number)
    .mass(SHELL_SPRING.mass as number);
}

interface ActionRailContextValue {
  expanded: boolean;
  setExpanded: (expanded: boolean) => void;
  notifyAction: (value?: string) => void;
  palette: ActionRailPalette;
  metrics: ActionRailMetrics;
  reduceMotion: boolean;
}

const ActionRailContext = createContext<ActionRailContextValue | null>(null);

function useActionRail(part: string) {
  const ctx = useContext(ActionRailContext);
  if (!ctx) throw new Error(`<${part}> must be rendered inside <ActionRail>.`);
  return ctx;
}

const ActionRailTintContext = createContext<string | null>(null);

function useActionRailTint(fallback: string): string {
  return useContext(ActionRailTintContext) ?? fallback;
}

export interface ActionRailProps {
  children: React.ReactNode;
  className?: string;
  expanded?: boolean;
  defaultExpanded?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
  onAction?: (value?: string) => void;
  collapseOnAction?: boolean;
  size?: ActionRailSize;
  theme?: ActionRailTheme;
  palette?: Partial<ActionRailPalette>;
}

/**
 * Expandable icon rail/toolbar: a track of always-visible actions plus a
 * `Trigger` that reveals an `Overflow` group of labeled actions. Ships its
 * own light/dark palette (independent of the app's shadcn theme tokens) so
 * the rail can sit as a floating overlay control on top of photos/maps/video
 * — override any color via the `palette` prop.
 */
export function ActionRail({
  children,
  className,
  expanded: expandedProp,
  defaultExpanded = false,
  onExpandedChange,
  onAction,
  collapseOnAction = false,
  size = "md",
  theme: themeProp,
  palette: paletteProp,
}: ActionRailProps) {
  const isControlled = expandedProp !== undefined;
  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
  const expanded = isControlled ? (expandedProp as boolean) : internalExpanded;
  const reduceMotion = useReducedMotion();
  const systemDark = useColorScheme() === "dark";
  const theme = themeProp ?? (systemDark ? "dark" : "light");

  const setExpanded = useCallback(
    (next: boolean) => {
      if (!isControlled) setInternalExpanded(next);
      onExpandedChange?.(next);
    },
    [isControlled, onExpandedChange]
  );

  const notifyAction = useCallback(
    (value?: string) => {
      onAction?.(value);
      if (collapseOnAction) setExpanded(false);
    },
    [onAction, collapseOnAction, setExpanded]
  );

  const palette = useMemo<ActionRailPalette>(() => ({ ...PALETTES[theme], ...paletteProp }), [theme, paletteProp]);
  const metrics = SIZES[size];

  const ctx = useMemo<ActionRailContextValue>(
    () => ({ expanded, setExpanded, notifyAction, palette, metrics, reduceMotion }),
    [expanded, setExpanded, notifyAction, palette, metrics, reduceMotion]
  );

  return (
    <ActionRailContext.Provider value={ctx}>
      <View className={className}>
        <Animated.View
          layout={reduceMotion ? undefined : shellLayout()}
          className="flex-row items-center self-start overflow-hidden border"
          style={{
            backgroundColor: palette.track,
            borderColor: palette.border,
            borderRadius: metrics.actionHeight / 2 + metrics.trackPadding,
            padding: metrics.trackPadding,
            gap: metrics.gap,
          }}
        >
          {children}
        </Animated.View>
      </View>
    </ActionRailContext.Provider>
  );
}

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

export function ActionRailGroup({ children, className }: ActionRailRowProps) {
  const { metrics, reduceMotion } = useActionRail("ActionRailGroup");
  return (
    <Animated.View
      layout={reduceMotion ? undefined : shellLayout()}
      className={cn("flex-row items-center", className)}
      style={{ gap: metrics.gap }}
    >
      {children}
    </Animated.View>
  );
}

export function ActionRailOverflow({ children, className }: ActionRailRowProps) {
  const { expanded, metrics, reduceMotion } = useActionRail("ActionRailOverflow");
  if (!expanded) return null;
  return (
    <Animated.View
      entering={reduceMotion ? undefined : FadeIn.duration(220)}
      exiting={reduceMotion ? undefined : FadeOut.duration(140)}
      layout={reduceMotion ? undefined : shellLayout()}
      className={cn("flex-row items-center", className)}
      style={{ gap: metrics.gap }}
    >
      {children}
    </Animated.View>
  );
}

export interface ActionRailActionProps {
  children: React.ReactNode;
  value?: string;
  onPress?: () => void;
  disabled?: boolean;
  className?: string;
}

export function ActionRailAction({ children, value, onPress, disabled = false, className }: ActionRailActionProps) {
  const { palette, metrics, notifyAction, reduceMotion } = useActionRail("ActionRailAction");
  const pressed = useSharedValue(0);
  const hitSlop = Math.max(0, (48 - metrics.actionHeight) / 2);

  const pressStyle = useAnimatedStyle(() => ({
    transform: [{ scale: 1 - (1 - PRESS_SCALE) * (reduceMotion ? 0 : pressed.value) }],
  }));

  const handlePress = useCallback(() => {
    onPress?.();
    notifyAction(value);
  }, [onPress, notifyAction, value]);

  return (
    <Animated.View style={pressStyle}>
      <Pressable
        accessible={true}
        accessibilityRole="button"
        disabled={disabled}
        hitSlop={hitSlop}
        onPress={handlePress}
        onPressIn={() => { pressed.value = withSpring(1, PRESS_SPRING); }}
        onPressOut={() => { pressed.value = withSpring(0, PRESS_SPRING); }}
        className={cn("flex-row items-center justify-center", disabled && "opacity-45", className)}
        style={{
          backgroundColor: palette.action,
          height: metrics.actionHeight,
          minWidth: metrics.actionHeight,
          borderRadius: metrics.actionHeight / 2,
          paddingHorizontal: metrics.actionPaddingX,
          gap: metrics.actionGap,
        }}
      >
        <ActionRailTintContext.Provider value={palette.actionText}>{children}</ActionRailTintContext.Provider>
      </Pressable>
    </Animated.View>
  );
}

export interface ActionRailIconProps {
  children: React.ReactNode | ((state: { color: string; size: number }) => React.ReactNode);
  className?: string;
}

export function ActionRailIcon({ children, className }: ActionRailIconProps) {
  const { palette, metrics } = useActionRail("ActionRailIcon");
  const color = useActionRailTint(palette.actionText);
  const content = typeof children === "function" ? children({ color, size: metrics.iconSize }) : children;

  return (
    <View className={cn("items-center justify-center", className)} style={{ width: metrics.iconSize, height: metrics.iconSize }}>
      {content}
    </View>
  );
}

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

export function ActionRailLabel({ children, className }: ActionRailLabelProps) {
  const { palette, metrics } = useActionRail("ActionRailLabel");
  const color = useActionRailTint(palette.actionText);
  return (
    <Text numberOfLines={1} className={cn("font-medium", className)} style={{ color, fontSize: metrics.fontSize }}>
      {children}
    </Text>
  );
}

export interface ActionRailTriggerProps {
  children?: React.ReactNode | ((state: { expanded: boolean; color: string; size: number }) => React.ReactNode);
  className?: string;
}

export function ActionRailTrigger({ children, className }: ActionRailTriggerProps) {
  const { expanded, setExpanded, palette, metrics, reduceMotion } = useActionRail("ActionRailTrigger");
  const pressed = useSharedValue(0);
  const hitSlop = Math.max(0, (48 - metrics.toggleSize) / 2);

  const pressStyle = useAnimatedStyle(() => ({
    transform: [{ scale: 1 - (1 - PRESS_SCALE) * (reduceMotion ? 0 : pressed.value) }],
  }));

  const state = { expanded, color: palette.toggleText, size: metrics.iconSize };
  const content = typeof children === "function" ? children(state) : (children ?? null);

  return (
    <Animated.View layout={reduceMotion ? undefined : shellLayout()} style={pressStyle}>
      <Pressable
        accessible={true}
        accessibilityRole="button"
        accessibilityState={{ expanded }}
        accessibilityLabel={expanded ? "Hide extra actions" : "Show extra actions"}
        hitSlop={hitSlop}
        onPress={() => setExpanded(!expanded)}
        onPressIn={() => { pressed.value = withSpring(1, PRESS_SPRING); }}
        onPressOut={() => { pressed.value = withSpring(0, PRESS_SPRING); }}
        className={cn("items-center justify-center", className)}
        style={{ backgroundColor: palette.toggle, width: metrics.toggleSize, height: metrics.toggleSize, borderRadius: metrics.toggleSize / 2 }}
      >
        <ActionRailTintContext.Provider value={palette.toggleText}>
          {content ?? (
            <DefaultTriggerGlyph expanded={expanded} color={palette.toggleText} size={metrics.fontSize} reduceMotion={reduceMotion} />
          )}
        </ActionRailTintContext.Provider>
      </Pressable>
    </Animated.View>
  );
}

interface DefaultTriggerGlyphProps {
  expanded: boolean;
  color: string;
  size: number;
  reduceMotion: boolean;
}

// Cross-fades the "more" glyph into a "close" glyph, each scaling up from
// GLYPH_MIN_SCALE as it appears and back down as it leaves, plus a quarter
// turn, so the swap reads as one continuous rotation rather than a hard cut.
function DefaultTriggerGlyph({ expanded, color, size, reduceMotion }: DefaultTriggerGlyphProps) {
  const progress = useSharedValue(expanded ? 1 : 0);

  useEffect(() => {
    const target = expanded ? 1 : 0;
    progress.value = reduceMotion ? target : withSpring(target, GLYPH_SPRING);
  }, [expanded, reduceMotion, progress]);

  const moreStyle = useAnimatedStyle(() => {
    const shown = 1 - progress.value;
    return {
      opacity: shown,
      transform: [
        { scale: GLYPH_MIN_SCALE + (1 - GLYPH_MIN_SCALE) * shown },
        { rotate: `${progress.value * 90}deg` },
      ],
    };
  });

  const closeStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [
      { scale: GLYPH_MIN_SCALE + (1 - GLYPH_MIN_SCALE) * progress.value },
      { rotate: `${(progress.value - 1) * 90}deg` },
    ],
  }));

  return (
    <View className="items-center justify-center">
      <Animated.View style={moreStyle}>
        <Ellipsis size={size + 4} color={color} strokeWidth={2.5} />
      </Animated.View>
      <Animated.View className="absolute" style={closeStyle}>
        <X size={size + 4} color={color} strokeWidth={2.5} />
      </Animated.View>
    </View>
  );
}