AniUI

Mobile Dock

macOS-style dock — drag your finger across the icons and a fisheye magnifier tracks your touch, growing the nearest icons and easing back as you move away.

M
S
P
T
S
Web preview — components render natively on iOS & Android
import { MobileDock } from "@/components/ui/mobile-dock";
<MobileDock items={dockItems} />

Installation#

npx @aniui/cli add mobile-dock

Usage#

import { MobileDock } from "@/components/ui/mobile-dock";
<MobileDock items={dockItems} />

Source#

components/ui/mobile-dock.tsx
import React, { useCallback, useMemo } from "react";
import { View, Text, LayoutChangeEvent } from "react-native";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  interpolate,
  Extrapolation,
  runOnJS,
  type SharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { cn } from "@/lib/utils";

export type DockItem = {
  key: string;
  icon: React.ReactNode;
  label?: string;
  onPress?: () => void;
};

export interface MobileDockProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  items: DockItem[];
  /** Resting icon size (dp) when nothing is being touched. */
  itemSize?: number;
  /** Icon size (dp) directly under the finger. */
  peakSize?: number;
  /** How many neighbors on either side still feel the magnification falloff. */
  spread?: number;
  /** Horizontal gap (dp) between resting icons. */
  gap?: number;
  /** Show a floating label above the touched icon. */
  showLabels?: boolean;
  onItemPress?: (item: DockItem, index: number) => void;
}

// Fractional icon index under a touch: x=0 -> -0.5 (left of the first icon),
// x=width -> count-0.5 (right of the last icon). Landing exactly on an
// integer means the finger sits at that icon's center.
function touchToIndex(x: number, width: number, count: number) {
  "worklet";
  if (width <= 0) return -1;
  return (x / width) * count - 0.5;
}

// Conserved-width fisheye size for one icon: everyone's growth is funded by
// shrinking everyone (including itself) by an even share of the dock-wide
// extra width picked up under the touch, so the row's total width never
// changes — only the finger's neighborhood visibly redistributes it.
function iconSize(
  index: number,
  count: number,
  activeIndex: number,
  activeAmount: number,
  restSize: number,
  inputRange: number[],
  outputRange: number[]
) {
  "worklet";
  // activeAmount is always blended from a fixed [0, 1] range, so the blend is
  // plain arithmetic (interpolate(t, [0,1], [a,b]) === a + t * (b - a)) rather
  // than a call to interpolate() with a freshly-built array literal each loop
  // iteration — that pattern was crashing on Android inside a worklet.
  const n = Math.max(count, 1);
  let totalExtra = 0;
  for (let j = 0; j < n; j++) {
    const distance = Math.abs(activeIndex - j);
    const grown = interpolate(distance, inputRange, outputRange, Extrapolation.CLAMP);
    totalExtra += activeAmount * (grown - restSize);
  }
  const myDistance = Math.abs(activeIndex - index);
  const myGrown = interpolate(myDistance, inputRange, outputRange, Extrapolation.CLAMP);
  const mine = restSize + activeAmount * (myGrown - restSize);
  return mine - totalExtra / n;
}

const ACTIVATE_SPRING = { damping: 22, stiffness: 400, mass: 0.35 };
const SETTLE_SPRING = { damping: 27, stiffness: 242, mass: 0.4 };
const ISACTIVE_INPUT = [0, 1];
const TOOLTIP_INPUT = [0, 0.5];
const TOOLTIP_OPACITY_OUTPUT = [1, 0];
const TOOLTIP_TRANSLATE_OUTPUT = [6, 0];

export function MobileDock({
  className,
  items,
  itemSize = 50,
  peakSize = 70,
  spread = 1,
  gap = 6,
  showLabels = true,
  onItemPress,
  onLayout,
  ...props
}: MobileDockProps) {
  const activeIndex = useSharedValue(-1);
  const isActive = useSharedValue(0);
  const dockWidth = useSharedValue(0);
  // Touch coordinates are relative to the gesture-attached (outer, padded)
  // view, but the icons only occupy the inner row inside that padding —
  // without this offset, touchToIndex mapped the raw touch x against the
  // full padded width, which under-registered touches near the edges (most
  // noticeably: holding the last icon could resolve to the previous one).
  const rowOffsetX = useSharedValue(0);
  const count = items.length;

  // spread=1 (default) -> inputRange [0, 1], outputRange [peakSize, itemSize]:
  // the touched icon peaks, its immediate neighbors are back to rest, and
  // everything beyond that is clamped at rest too.
  const { inputRange, outputRange } = useMemo(() => {
    const input: number[] = [];
    const output: number[] = [];
    for (let d = 0; d <= spread; d++) {
      input.push(d);
      output.push(d === 0 ? peakSize : itemSize + (peakSize - itemSize) * Math.max(0, 1 - d / spread));
    }
    return { inputRange: input, outputRange: output };
  }, [spread, itemSize, peakSize]);

  const fireTap = useCallback(
    (idx: number) => {
      const item = items[Math.round(idx)];
      if (!item) return;
      item.onPress?.();
      onItemPress?.(item, Math.round(idx));
    },
    [items, onItemPress]
  );

  const gesture = useMemo(
    () =>
      Gesture.Manual()
        .onTouchesDown((e, manager) => {
          if (e.allTouches.length === 0) return;
          activeIndex.value = touchToIndex(e.allTouches[0].x - rowOffsetX.value, dockWidth.value, count);
          isActive.value = withSpring(1, ACTIVATE_SPRING);
          manager.activate();
        })
        .onTouchesMove((e) => {
          if (e.allTouches.length === 0) return;
          activeIndex.value = touchToIndex(e.allTouches[0].x - rowOffsetX.value, dockWidth.value, count);
        })
        .onTouchesUp((e) => {
          const idx = e.allTouches.length > 0 ? touchToIndex(e.allTouches[0].x - rowOffsetX.value, dockWidth.value, count) : activeIndex.value;
          if (idx >= -0.5) runOnJS(fireTap)(idx);
          isActive.value = withSpring(0, SETTLE_SPRING);
        })
        .onTouchesCancelled((_e, manager) => {
          isActive.value = withSpring(0, SETTLE_SPRING);
          manager.end();
        })
        .shouldCancelWhenOutside(false),
    [activeIndex, isActive, dockWidth, rowOffsetX, count, fireTap]
  );

  const handleRowLayout = (e: LayoutChangeEvent) => {
    dockWidth.value = e.nativeEvent.layout.width;
    rowOffsetX.value = e.nativeEvent.layout.x;
  };

  return (
    <GestureDetector gesture={gesture}>
      <Animated.View
        className={cn("flex-row items-end rounded-2xl border border-border bg-card/90 px-3 pb-2 pt-1", className)}
        onLayout={onLayout}
        accessibilityRole="toolbar"
        {...props}
      >
        <View className="flex-1 flex-row items-end" onLayout={handleRowLayout}>
          {items.map((item, index) => (
            <DockIcon
              key={item.key}
              item={item}
              index={index}
              count={count}
              activeIndex={activeIndex}
              isActive={isActive}
              itemSize={itemSize}
              gap={gap}
              inputRange={inputRange}
              outputRange={outputRange}
              showLabel={showLabels && !!item.label}
            />
          ))}
        </View>
      </Animated.View>
    </GestureDetector>
  );
}

function DockIcon({
  item,
  index,
  count,
  activeIndex,
  isActive,
  itemSize,
  gap,
  inputRange,
  outputRange,
  showLabel,
}: {
  item: DockItem;
  index: number;
  count: number;
  activeIndex: SharedValue<number>;
  isActive: SharedValue<number>;
  itemSize: number;
  gap: number;
  inputRange: number[];
  outputRange: number[];
  showLabel: boolean;
}) {
  // The wrapper's own width/height grow into the flex row (which is
  // bottom-aligned via `items-end`), so the slot itself pushes upward.
  const wrapperStyle = useAnimatedStyle(() => {
    const size = iconSize(index, count, activeIndex.value, isActive.value, itemSize, inputRange, outputRange);
    return { width: size, height: size };
  });

  // The icon inside is a fixed-size box scaled via transform. RN scales
  // around the center, which would grow the icon downward too — translateY
  // cancels exactly that downward half so growth reads as upward-only.
  const iconStyle = useAnimatedStyle(() => {
    const size = iconSize(index, count, activeIndex.value, isActive.value, itemSize, inputRange, outputRange);
    const scale = size / itemSize;
    return { transform: [{ translateY: -(itemSize * (scale - 1)) / 2 }, { scale }] };
  });

  const tooltipStyle = useAnimatedStyle(() => {
    const distance = Math.abs(activeIndex.value - index);
    const opacity = isActive.value * interpolate(distance, TOOLTIP_INPUT, TOOLTIP_OPACITY_OUTPUT, Extrapolation.CLAMP);
    const translateY = interpolate(isActive.value, ISACTIVE_INPUT, TOOLTIP_TRANSLATE_OUTPUT);
    return { opacity, transform: [{ translateY }] };
  });

  return (
    <Animated.View
      style={[{ marginHorizontal: gap / 2 }, wrapperStyle]}
      className="items-center justify-end overflow-visible"
    >
      {showLabel && (
        <Animated.View
          style={tooltipStyle}
          pointerEvents="none"
          className="absolute -top-8 rounded-md bg-primary px-2 py-1"
        >
          <Text numberOfLines={1} className="text-[11px] font-medium text-primary-foreground">
            {item.label}
          </Text>
        </Animated.View>
      )}
      <Animated.View
        style={[{ width: itemSize, height: itemSize }, iconStyle]}
        className="items-center justify-center rounded-xl bg-secondary"
        accessible
        accessibilityRole="button"
        accessibilityLabel={item.label ?? item.key}
      >
        {item.icon}
      </Animated.View>
    </Animated.View>
  );
}