Kinetik UI

Dock Bar

.md

A floating bottom navigation bar whose items swell under the finger like a dock.

v1.0.0Live on webreact-native-gesture-handlerreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add dock-bar

The CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/dock-bar.json

Install manually

  1. Install dependencies
    npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/dock-bar.tsx
    View source files

Usage

import { DockBar } from '@/components/kinetik/dock-bar';

<DockBar
  accessibilityLabel="Main"
  value={tab}
  onChange={setTab}
  items={[
    { key: 'home', label: 'Home', icon: ({ color }) => <HomeIcon color={color} /> },
    { key: 'search', label: 'Search', icon: ({ color }) => <SearchIcon color={color} /> },
    { key: 'profile', label: 'Profile', icon: ({ color }) => <ProfileIcon color={color} /> },
  ]}
/>;

Place it at the bottom of the screen above the safe area. icon receives the active state and a colour so your icons follow the theme.

When to use

  • Primary navigation with three to six destinations.
  • Apps with personality where the bar itself is part of the brand.

When not to use

  • More than six items; the dock gets cramped and the magnification overlaps.
  • Screens that need every pixel; a floating dock covers content beneath it.

Notes

  • A tap chooses an item; sliding previews items with a label bubble and a tick per item, and releasing chooses the one under the finger.
  • The bar claims the touch as soon as the finger lands, so it should not sit inside a horizontal scroll view.
  • Under reduced motion the items keep their size; the label still shows while sliding.

Props

DockBarProps

PropTypeDefaultDescription
itemsrequiredreadonly DockItem[]—
valuerequiredstring—Key of the active item.
onChangerequired(key: string) => void—
magnificationnumber1.6Size of the item under the finger relative to rest. Defaults to 1.6.
itemSizenumber48Item size at rest, in pt. Defaults to 48.
hapticsbooleantrueTicks as the finger crosses items. Defaults to true.
accessibilityLabelstring—Accessible name of the bar.
styleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
While touching, one shared value (finger position) drives one transform per item on the UI thread; React renders only when the hovered item changes.
Measured

Pixel 9 emulator, API 35, release build · interaction median 1.12× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 150 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
No magnification; items stay their rest size and the label bubble still shows.
Screen readers
Tab list; each item is a tab with its label and selected state, and 'activate' chooses it.
Touch target
Items are 48 pt square by default (minimum 40).

Try it on a device

Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.

Source files

components/kinetik/dock-bar.tsx
import { useState, type ReactNode } from 'react';
import {
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
  type StyleProp,
  type ViewStyle,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useAnimatedReaction,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { minTouchTarget, withAlpha } from '../../lib/kinetik/tokens/tokens';

export type DockItem = {
  key: string;
  label: string;
  /** Icon drawn inside the item; receive the active state to style it. */
  icon: (state: { active: boolean; color: string }) => ReactNode;
};

export type DockBarProps = {
  items: readonly DockItem[];
  /** Key of the active item. */
  value: string;
  onChange: (key: string) => void;
  /** Size of the item under the finger relative to rest. Defaults to 1.6. */
  magnification?: number;
  /** Item size at rest, in pt. Defaults to 48. */
  itemSize?: number;
  /** Ticks as the finger crosses items. Defaults to true. */
  haptics?: boolean;
  /** Accessible name of the bar. */
  accessibilityLabel?: string;
  style?: StyleProp<ViewStyle>;
};

const GAP = 6;
const TOOLTIP_WIDTH = 160;

/**
 * A floating bottom navigation bar that swells under the finger like a dock.
 * Slide along it to preview items, release to choose; a tap chooses directly.
 */
export function DockBar({
  items,
  value,
  onChange,
  magnification = 1.6,
  itemSize = 48,
  haptics = true,
  accessibilityLabel,
  style,
}: DockBarProps) {
  const theme = useKinetikTheme();
  const reduced = useReduceMotion();
  const haptic = useHaptic(haptics);
  const mag = reduced ? 1 : Math.max(1, Math.min(2.2, magnification));
  const pad = theme.space[2];
  const dark = theme.scheme === 'dark';

  const finger = useSharedValue(-1000);
  const touching = useSharedValue(0);
  const hovered = useSharedValue(-1);
  const [preview, setPreview] = useState<number | null>(null);

  const step = itemSize + GAP;
  const indexAt = (x: number) => {
    'worklet';
    const i = Math.floor((x - pad + GAP / 2) / step);
    return Math.max(0, Math.min(items.length - 1, i));
  };

  const choose = (i: number) => {
    const item = items[i];
    setPreview(null);
    if (item && item.key !== value) onChange(item.key);
  };
  const show = (i: number) => {
    setPreview(i < 0 ? null : i);
    if (i >= 0) haptic('tick');
  };

  // Tick and show the label whenever the finger moves onto a new item.
  useAnimatedReaction(
    () => (touching.get() > 0.5 ? indexAt(finger.get()) : -1),
    (i, prev) => {
      if (i !== prev) {
        hovered.set(i);
        scheduleOnRN(show, i);
      }
    },
  );

  const pan = Gesture.Pan()
    .withTestId('dock-bar')
    .minDistance(0)
    .shouldCancelWhenOutside(false)
    .onBegin((e) => {
      finger.set(e.x);
      touching.set(withSpring(1, spring('snappy', reduced)));
    })
    .onUpdate((e) => {
      finger.set(e.x);
    })
    .onEnd((e) => {
      scheduleOnRN(choose, indexAt(e.x));
    })
    .onFinalize(() => {
      touching.set(withSpring(0, spring('smooth', reduced)));
      hovered.set(-1);
      scheduleOnRN(show, -1);
    });

  // The bar's backdrop widens as the outer items are pushed out, so they never
  // hang over its edge. Only a transform: no layout work per frame.
  const barWidth = pad * 2 + items.length * itemSize + (items.length - 1) * GAP;
  const spread = (mag - 1) * itemSize * 0.75 * 2;
  const backdrop = useAnimatedStyle(() => ({
    transform: [{ scaleX: 1 + (spread / barWidth) * touching.get() }],
  }));

  const activeIndex = items.findIndex((i) => i.key === value);
  const previewItem = preview === null ? null : items[preview];

  return (
    <View style={[styles.wrap, style]}>
      {previewItem ? (
        <View
          pointerEvents="none"
          style={[
            styles.tooltip,
            {
              left: pad + (preview ?? 0) * step + itemSize / 2 - TOOLTIP_WIDTH / 2,
              bottom: itemSize * mag + pad * 2 + 4,
            },
          ]}
        >
          <View
            style={[
              styles.tooltipInner,
              {
                backgroundColor: theme.colors.text,
                borderRadius: theme.radius.xs,
                boxShadow: theme.shadow.md,
              },
            ]}
          >
            <Text
              style={{
                color: theme.colors.background,
                fontSize: theme.font.size.xs,
                fontWeight: theme.font.weight.semibold,
              }}
            >
              {previewItem.label}
            </Text>
          </View>
          <View style={[styles.caret, { backgroundColor: theme.colors.text }]} />
        </View>
      ) : null}
      <GestureDetector gesture={pan}>
        <View
          collapsable={false}
          accessibilityRole="tablist"
          accessibilityLabel={accessibilityLabel}
          style={[styles.bar, { padding: pad, gap: GAP }]}
        >
          <Animated.View
            pointerEvents="none"
            style={[
              styles.backdrop,
              {
                borderRadius: itemSize / 2 + pad,
                backgroundColor: withAlpha(theme.colors.surfaceRaised, dark ? 0.78 : 0.86),
                borderColor: theme.colors.borderStrong,
                // A soft drop shadow and a fine highlight along the top edge, like glass.
                boxShadow: `${theme.shadow.lg}, inset 0px 1px 0px ${withAlpha(theme.colors.highlight, dark ? 0.1 : 0.9)}`,
              },
              backdrop,
            ]}
          />
          {items.map((item, i) => (
            <DockSlot
              key={item.key}
              item={item}
              i={i}
              active={i === activeIndex}
              size={itemSize}
              step={step}
              pad={pad}
              mag={mag}
              finger={finger}
              touching={touching}
              onActivate={() => choose(i)}
            />
          ))}
        </View>
      </GestureDetector>
    </View>
  );
}

function DockSlot({
  item,
  i,
  active,
  size,
  step,
  pad,
  mag,
  finger,
  touching,
  onActivate,
}: {
  item: DockItem;
  i: number;
  active: boolean;
  size: number;
  step: number;
  pad: number;
  mag: number;
  finger: SharedValue<number>;
  touching: SharedValue<number>;
  onActivate: () => void;
}) {
  const { colors, scheme } = useKinetikTheme();
  const dark = scheme === 'dark';
  const center = pad + i * step + size / 2;

  // Gaussian falloff around the finger; items grow upward from the bar.
  const animated = useAnimatedStyle(() => {
    const d = (finger.get() - center) / (size * 1.25);
    const s = 1 + (mag - 1) * Math.exp(-d * d) * touching.get();
    // Neighbours make room: items shift away from the finger as they grow.
    const push = -Math.tanh(d) * (mag - 1) * size * 0.75 * touching.get();
    return {
      transform: [{ translateX: push }, { translateY: (-(s - 1) * size) / 2 }, { scale: s }],
    };
  });

  const onAccessibilityAction = (e: AccessibilityActionEvent) => {
    if (e.nativeEvent.actionName === 'activate') onActivate();
  };

  const tint = active ? colors.onEmber : colors.textMuted;
  // Tiles are lit from above: a lighter top fading down, and a bright rim.
  const tile = active
    ? { backgroundColor: colors.ember, borderColor: colors.ember }
    : {
        backgroundColor: colors.surface,
        borderColor: colors.border,
        ...gradient(
          `linear-gradient(180deg, ${withAlpha(colors.highlight, dark ? 0.09 : 1)} 0%, ${withAlpha(colors.highlight, dark ? 0.02 : 0.4)} 100%)`,
        ),
      };

  return (
    <Animated.View
      accessible
      accessibilityRole="tab"
      accessibilityLabel={item.label}
      accessibilityState={{ selected: active }}
      accessibilityActions={[{ name: 'activate' }]}
      onAccessibilityAction={onAccessibilityAction}
      style={{ width: size, height: size }}
    >
      <Animated.View
        style={[
          styles.item,
          tile,
          {
            width: size,
            height: size,
            // Continuous-looking corners: about 30% of the side, as on app icons.
            borderRadius: size * 0.3,
            boxShadow: active
              ? `0px 4px 14px ${withAlpha(colors.ember, dark ? 0.25 : 0.3)}`
              : `inset 0px 1px 0px ${withAlpha(colors.highlight, dark ? 0.08 : 1)}`,
          },
          animated,
        ]}
      >
        {item.icon({ active, color: tint })}
      </Animated.View>
      <View style={[styles.dot, { backgroundColor: active ? colors.text : 'transparent' }]} />
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  wrap: { alignSelf: 'center' },
  bar: { flexDirection: 'row', alignItems: 'flex-end', minHeight: minTouchTarget },
  backdrop: { ...StyleSheet.absoluteFill, borderWidth: StyleSheet.hairlineWidth },
  item: { alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth },
  dot: {
    position: 'absolute',
    bottom: -5,
    alignSelf: 'center',
    width: 3,
    height: 3,
    borderRadius: 1.5,
  },
  tooltip: { position: 'absolute', width: TOOLTIP_WIDTH, alignItems: 'center' },
  tooltipInner: { paddingHorizontal: 10, paddingVertical: 5 },
  caret: { width: 8, height: 8, marginTop: -5, transform: [{ rotate: '45deg' }], zIndex: -1 },
});