Toolbar

Flexible toolbar component with animated layout transitions and mode switching.

Basic Toolbar
Advanced Toolbar
Dynamic Toolbar

Library setup

npx shadcn@latest add button

Code

"use client";

import { MotionConfig, motion } from "motion/react";

import React, {
  createContext,
  forwardRef,
  useCallback,
  useContext,
  useState,
} from "react";

import { Button } from "@/components/ui/button";

import { cn } from "@/lib/utils";

type ToolbarMode = string;
type ToolbarPosition = "center" | "top" | "bottom" | "left" | "right";

interface ToolbarTransition {
  type?: "spring" | "tween";
  bounce?: number;
  duration?: number;
  stiffness?: number;
  damping?: number;
}

interface ToolbarContextValue {
  mode: ToolbarMode;
  setMode: (mode: ToolbarMode) => void;
  transition: ToolbarTransition;
  onModeChange?: (mode: ToolbarMode, previousMode: ToolbarMode) => void;
}

interface ToolbarProps {
  defaultMode?: ToolbarMode;
  mode?: ToolbarMode;
  onModeChange?: (mode: ToolbarMode, previousMode: ToolbarMode) => void;
  position?: ToolbarPosition;
  transition?: ToolbarTransition;
  className?: string;
  children: React.ReactNode;
}

interface ToolbarContentProps {
  mode: ToolbarMode;
  className?: string;
  children: React.ReactNode;
}

interface ToolbarInputProps {
  placeholder?: string;
  value?: string;
  onChange?: (value: string) => void;
  onSubmit?: (value: string) => void;
  className?: string;
  autoFocus?: boolean;
  layoutId?: string;
}

interface ToolbarItemProps {
  children: React.ReactNode;
  className?: string;
  onClick?: () => void;
  label?: string;
  disabled?: boolean;
}

interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<"button"> {
  targetMode?: ToolbarMode;
  label?: string;
  onClick?: () => void;
  children: React.ReactNode;
}

const ToolbarContext = createContext<ToolbarContextValue | null>(null);

const useToolbar = () => {
  const context = useContext(ToolbarContext);

  if (!context) {
    throw new Error("Toolbar components must be used within Toolbar");
  }
  return context;
};

const defaultTransition: ToolbarTransition = {
  type: "spring",
  stiffness: 300,
  damping: 30,
  duration: 0.3,
};

const positionClasses = {
  center: "absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
  top: "absolute left-1/2 top-4 -translate-x-1/2",
  bottom: "absolute left-1/2 bottom-4 -translate-x-1/2",
  left: "absolute left-4 top-1/2 -translate-y-1/2",
  right: "absolute right-4 top-1/2 -translate-y-1/2",
};

const separatorClasses = {
  horizontal: "w-4 h-px my-1 mx-auto border-y",
  vertical: "h-4 w-px mx-1 my-auto border-x",
};

const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(
  (
    {
      defaultMode = "default",
      mode: controlledMode,
      onModeChange,
      position = "center",
      transition = defaultTransition,
      className,
      children,
      ...props
    },
    ref,
  ) => {
    const [internalMode, setInternalMode] = useState(defaultMode);
    const mode = controlledMode ?? internalMode;

    const setMode = useCallback(
      (newMode: ToolbarMode) => {
        const previousMode = mode;
        if (controlledMode === undefined) {
          setInternalMode(newMode);
        }
        onModeChange?.(newMode, previousMode);
      },
      [mode, controlledMode, onModeChange],
    );

    return (
      <ToolbarContext.Provider
        value={{ mode, setMode, transition, onModeChange }}
      >
        <MotionConfig transition={transition}>
          <div
            className={cn(positionClasses[position], className)}
            ref={ref}
            {...props}
          >
            <motion.div
              layout
              className={cn(
                "bg-popover text-popover-foreground inline-flex overflow-hidden rounded-2xl border shadow-sm",
              )}
              transition={{
                layout: {
                  type: "spring",
                  stiffness: 500,
                  damping: 30,
                  duration: 0.3,
                },
              }}
            >
              <motion.div
                key={mode}
                initial={{ opacity: 0, x: -10 }}
                animate={{ opacity: 1, x: 0 }}
                transition={{
                  duration: 0.2,
                  ease: "easeOut",
                  delay: 0.1,
                }}
                className="flex w-full flex-col items-stretch"
              >
                {children}
              </motion.div>
            </motion.div>
          </div>
        </MotionConfig>
      </ToolbarContext.Provider>
    );
  },
);
Toolbar.displayName = "Toolbar";

const ToolbarContent = forwardRef<HTMLDivElement, ToolbarContentProps>(
  ({ mode: contentMode, className, children, ...props }, ref) => {
    const { mode } = useToolbar();

    if (mode !== contentMode) return null;

    return (
      <div
        className={cn("flex w-full items-center p-1", className)}
        ref={ref}
        {...props}
      >
        {children}
      </div>
    );
  },
);
ToolbarContent.displayName = "ToolbarContent";

const ToolbarInput = forwardRef<HTMLInputElement, ToolbarInputProps>(
  (
    {
      placeholder,
      value,
      onChange,
      onSubmit,
      className,
      autoFocus = true,
      ...props
    },
    ref,
  ) => {
    const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter" && onSubmit) {
        onSubmit(e.currentTarget.value);
      }
    };

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      onChange?.(e.target.value);
    };

    return (
      <div className="relative min-w-0 flex-1">
        <input
          ref={ref}
          value={value}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          className={cn(
            "h-9 w-full rounded-xl border-0 bg-transparent px-3 py-1",
            "ring-ring/40 ring-0 outline-hidden focus-visible:ring-0",
            "placeholder:text-muted-foreground text-sm",
            className,
          )}
          autoFocus={autoFocus}
          {...props}
        />
      </div>
    );
  },
);
ToolbarInput.displayName = "ToolbarInput";

const ToolbarItem = forwardRef<HTMLDivElement, ToolbarItemProps>(
  ({ children, className, label, onClick, ...props }, ref) => {
    return (
      <div
        ref={ref}
        className={cn("flex items-center", className)}
        onClick={onClick}
        {...props}
      >
        {children}
      </div>
    );
  },
);
ToolbarItem.displayName = "ToolbarItem";

const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(
  ({ targetMode, onClick, label, children, className, ...props }, ref) => {
    const { setMode } = useToolbar();

    const handleClick =
      onClick ?? (targetMode ? () => setMode(targetMode) : undefined);

    return (
      <Button
        ref={ref}
        variant="ghost"
        size="icon"
        onClick={handleClick}
        aria-label={
          label ||
          (targetMode ? `Set toolbar mode to ${targetMode}` : undefined)
        }
        className={cn("rounded-xl", className)}
        {...props}
      >
        {children}
      </Button>
    );
  },
);
ToolbarButton.displayName = "ToolbarButton";

const ToolbarSeparator = forwardRef<
  HTMLDivElement,
  { className?: string; variants?: "vertical" | "horizontal" }
>(({ className, variants = "vertical", ...props }, ref) => {
  return (
    <div
      ref={ref}
      className={cn("shrink-0", className, separatorClasses[variants])}
      {...props}
    />
  );
});
ToolbarSeparator.displayName = "ToolbarSeparator";

export {
  Toolbar,
  ToolbarContent,
  ToolbarItem,
  ToolbarInput,
  ToolbarSeparator,
  ToolbarButton,
};