AniUI

Hamburger

Animated hamburger ⇄ close icon morph driven by a single progress value.

Web preview — components render natively on iOS & Android
import { Hamburger } from "@/components/ui/hamburger";
<Hamburger open={open} onOpenChange={setOpen} />

Installation#

npx @aniui/cli add hamburger

Usage#

import { Hamburger } from "@/components/ui/hamburger";
<Hamburger open={open} onOpenChange={setOpen} />

Source#

components/ui/hamburger.tsx
import React, { useEffect } from "react";
import { Pressable } from "react-native";
import Animated, { Easing, interpolate, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
import { cn } from "@/lib/utils";
import { useThemeColors } from "@/components/ui/theme-provider";

export interface HamburgerProps extends Omit<React.ComponentPropsWithoutRef<typeof Pressable>, "onPress"> {
  className?: string;
  /** Fully controlled, like AniUI's Toggle. */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  size?: number;
  color?: string;
}

/** Animated hamburger <-> close icon morph — three bars rearranging into an X. */
export function Hamburger({ className, open = false, onOpenChange, size = 28, color, ...props }: HamburgerProps) {
  const colors = useThemeColors();
  const tint = color ?? colors.foreground;
  const progress = useSharedValue(open ? 1 : 0);

  useEffect(() => {
    progress.value = withTiming(open ? 1 : 0, { duration: 280, easing: Easing.inOut(Easing.quad) });
  }, [open, progress]);

  const lineWidth = size * 0.72;
  const strokeWidth = Math.max(2, size * 0.08);
  const gap = size * 0.26;
  const lineStyle = { width: lineWidth, height: strokeWidth, backgroundColor: tint };

  const topStyle = useAnimatedStyle(() => ({
    transform: [
      { translateY: interpolate(progress.value, [0, 1], [-gap, 0]) },
      { rotate: `${interpolate(progress.value, [0, 1], [0, 45])}deg` },
    ],
  }));
  const middleStyle = useAnimatedStyle(() => ({
    opacity: interpolate(progress.value, [0, 0.6, 1], [1, 0, 0]),
    transform: [{ scale: interpolate(progress.value, [0, 1], [1, 0]) }],
  }));
  const bottomStyle = useAnimatedStyle(() => ({
    transform: [
      { translateY: interpolate(progress.value, [0, 1], [gap, 0]) },
      { rotate: `${interpolate(progress.value, [0, 1], [0, -45])}deg` },
    ],
  }));

  return (
    <Pressable
      onPress={() => onOpenChange?.(!open)}
      accessibilityRole="button"
      accessibilityState={{ expanded: open }}
      accessibilityLabel={open ? "Close menu" : "Open menu"}
      accessible={true}
      className={cn("min-h-12 min-w-12 items-center justify-center", className)}
      style={{ width: size, height: size }}
      {...props}
    >
      <Animated.View style={[{ position: "absolute", borderRadius: 999 }, lineStyle, topStyle]} />
      <Animated.View style={[{ position: "absolute", borderRadius: 999 }, lineStyle, middleStyle]} />
      <Animated.View style={[{ position: "absolute", borderRadius: 999 }, lineStyle, bottomStyle]} />
    </Pressable>
  );
}