All components

AI Branch

AI Branch is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT other

What it is

AI Branch is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i lucide-react motion

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

AI Branch

index.tsx
"use client";

import { cn } from "@/lib/utils";
import { ChevronLeftIcon, ChevronRightIcon, Copy, Pencil } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";

interface AIBranchContextType {
  branches: ReactElement[];
  currentBranch: number;
  goToNext: () => void;
  goToPrevious: () => void;
  setBranches: (branches: ReactElement[]) => void;
  totalBranches: number;
}

/**
 * The same two springs every other AI component uses.
 *
 * This file predates that vocabulary and had eight hand-tuned
 * `stiffness`/`damping` pairs — several of which also passed `duration`, which
 * Motion ignores once stiffness is present. Sharing the tokens is what stops
 * `ai-branch` feeling like it came from a different library than `ai-message`.
 */
const SPRING_DEFAULT = {
  bounce: 0.1,
  duration: 0.25,
  type: "spring" as const,
};
const SPRING_SNAPPY = {
  bounce: 0,
  duration: 0.2,
  type: "spring" as const,
};

const AIBranchContext = createContext<AIBranchContextType | null>(null);

const useAIBranch = () => {
  const context = useContext(AIBranchContext);
  if (!context) {
    throw new Error("AIBranch components must be used within AIBranch");
  }
  return context;
};

export type AIBranchProps = HTMLAttributes<HTMLDivElement> & {
  defaultBranch?: number;
  onBranchChange?: (branchIndex: number) => void;
};

export const AIBranch = ({
  defaultBranch = 0,
  onBranchChange,
  className,
  ...props
}: AIBranchProps) => {
  const [currentBranch, setCurrentBranch] = useState(defaultBranch);
  const [branches, setBranches] = useState<ReactElement[]>([]);

  const handleBranchChange = (newBranch: number) => {
    setCurrentBranch(newBranch);
    onBranchChange?.(newBranch);
  };

  const goToPrevious = () => {
    const newBranch =
      currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
    handleBranchChange(newBranch);
  };

  const goToNext = () => {
    const newBranch =
      currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
    handleBranchChange(newBranch);
  };

  const contextValue: AIBranchContextType = {
    branches,
    currentBranch,
    goToNext,
    goToPrevious,
    setBranches,
    totalBranches: branches.length,
  };

  return (
    <AIBranchContext.Provider value={contextValue}>
      <div
        className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
        {...props}
      />
    </AIBranchContext.Provider>
  );
};

export interface AIBranchMessagesProps {
  children: ReactElement | ReactElement[];
}

export const AIBranchMessages = ({ children }: AIBranchMessagesProps) => {
  const { currentBranch, setBranches, branches } = useAIBranch();
  const shouldReduceMotion = useReducedMotion();
  const childrenArray = useMemo(
    () => (Array.isArray(children) ? children : [children]),
    [children]
  );

  // Use useEffect to update branches when they change
  useEffect(() => {
    if (branches.length !== childrenArray.length) {
      setBranches(childrenArray);
    }
  }, [childrenArray, branches, setBranches]);

  return childrenArray.map((branch, index) => (
    <motion.div
      animate={
        shouldReduceMotion
          ? { opacity: index === currentBranch ? 1 : 0 }
          : {
              display: index === currentBranch ? "block" : "none",
              opacity: index === currentBranch ? 1 : 0,
              y: index === currentBranch ? 0 : 10,
            }
      }
      className={cn(
        "grid gap-2 [&>div]:pb-0",
        index === currentBranch ? "block" : "hidden"
      )}
      initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 10 }}
      key={`branch-${index}-${currentBranch}`}
      transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}
    >
      {branch}
    </motion.div>
  ));
};

export type AIBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
  from: "user" | "assistant";
};

export const AIBranchSelector = ({
  className,
  from,
  ...props
}: AIBranchSelectorProps) => {
  const { totalBranches } = useAIBranch();

  // Don't render if there's only one branch
  if (totalBranches <= 1) {
    return null;
  }

  return (
    <div
      className={cn(
        "flex items-center gap-2 self-end px-10",
        from === "assistant" ? "justify-start" : "justify-end",
        className
      )}
      {...props}
    />
  );
};

export interface AIBranchPreviousProps {
  children?: ReactNode;
  className?: string;
}

export const AIBranchPrevious = ({
  className,
  children,
}: AIBranchPreviousProps) => {
  const { goToPrevious, totalBranches } = useAIBranch();
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.button
      aria-label="Previous branch"
      className={cn(
        "size-7 shrink-0 cursor-pointer rounded-full text-muted-foreground transition-colors",
        "hover:bg-muted hover:text-foreground",
        "disabled:pointer-events-none disabled:opacity-50",
        "flex items-center justify-center",
        className
      )}
      disabled={totalBranches <= 1}
      onClick={goToPrevious}
      transition={shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY}
      type="button"
      whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
      whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
    >
      {children ?? <ChevronLeftIcon size={14} />}
    </motion.button>
  );
};

export interface AIBranchNextProps {
  children?: ReactNode;
  className?: string;
}

export const AIBranchNext = ({ className, children }: AIBranchNextProps) => {
  const { goToNext, totalBranches } = useAIBranch();
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.button
      aria-label="Next branch"
      className={cn(
        "size-7 shrink-0 cursor-pointer rounded-full text-muted-foreground transition-colors",
        "hover:bg-muted hover:text-foreground",
        "disabled:pointer-events-none disabled:opacity-50",
        "flex items-center justify-center",
        className
      )}
      disabled={totalBranches <= 1}
      onClick={goToNext}
      transition={shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY}
      type="button"
      whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
      whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
    >
      {children ?? <ChevronRightIcon size={14} />}
    </motion.button>
  );
};

export interface AIBranchPageProps {
  className?: string;
}

export const AIBranchPage = ({ className }: AIBranchPageProps) => {
  const { currentBranch, totalBranches } = useAIBranch();

  return (
    <span
      className={cn(
        "font-medium text-muted-foreground text-xs tabular-nums",
        className
      )}
    >
      {currentBranch + 1} of {totalBranches}
    </span>
  );
};

// Updated type for conversation branches
export interface AIBranchData {
  aiResponse: string;
  id: string;
  isActive: boolean;
  timestamp: Date;
  userMessage: string;
}

// Export the type alias for backward compatibility
export type AIBranch = AIBranchData;

interface LegacyAiBranchProps {
  branches: AIBranchData[];
  className?: string;
  onBranchSelect: (branchId: string) => void;
}

// Updated legacy component to show conversation branches
export function LegacyAiBranch({
  branches,
  onBranchSelect,
  className,
}: LegacyAiBranchProps) {
  const shouldReduceMotion = useReducedMotion();
  const [currentBranchIndex, setCurrentBranchIndex] = useState(() =>
    branches.findIndex((branch) => branch.isActive)
  );

  const activeBranch = branches[currentBranchIndex];

  const goToPrevious = () => {
    const newIndex =
      currentBranchIndex > 0 ? currentBranchIndex - 1 : branches.length - 1;
    setCurrentBranchIndex(newIndex);
    onBranchSelect(branches[newIndex].id);
  };

  const goToNext = () => {
    const newIndex =
      currentBranchIndex < branches.length - 1 ? currentBranchIndex + 1 : 0;
    setCurrentBranchIndex(newIndex);
    onBranchSelect(branches[newIndex].id);
  };

  return (
    <div className={cn("w-full max-w-2xl", className)}>
      {/* Active Branch Display */}
      {activeBranch ? (
        <motion.div
          animate={{ opacity: 1, y: 0 }}
          className="mb-4 space-y-4"
          initial={{ opacity: 0, y: 10 }}
          transition={SPRING_DEFAULT}
        >
          {/* User Message with Branch Navigation */}
          <div className="flex justify-end">
            <div className="flex flex-col items-end gap-2">
              <div className="max-w-full rounded-2xl rounded-br-md bg-foreground px-3.5 py-2.5 text-background">
                <p className="text-sm leading-relaxed">
                  {activeBranch.userMessage}
                </p>
              </div>

              {/* Branch Navigation Controls */}
              {branches.length > 1 && (
                <div className="flex items-center gap-1">
                  <motion.button
                    aria-label="Copy message"
                    className={cn(
                      "size-6 shrink-0 cursor-pointer rounded text-foreground/70 transition-colors",
                      "hover:bg-muted hover:text-foreground",
                      "flex items-center justify-center"
                    )}
                    transition={
                      shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY
                    }
                    type="button"
                    whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
                    whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
                  >
                    <Copy className="h-3 w-3" />
                  </motion.button>

                  <motion.button
                    aria-label="Edit message"
                    className={cn(
                      "size-6 shrink-0 cursor-pointer rounded text-foreground/70 transition-colors",
                      "hover:bg-muted hover:text-foreground",
                      "flex items-center justify-center"
                    )}
                    transition={
                      shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY
                    }
                    type="button"
                    whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
                    whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
                  >
                    <Pencil className="h-3 w-3" />
                  </motion.button>

                  <motion.button
                    aria-label="Previous branch"
                    className={cn(
                      "size-6 shrink-0 cursor-pointer rounded text-foreground/70 transition-colors",
                      "hover:bg-muted hover:text-foreground",
                      "disabled:pointer-events-none disabled:opacity-50",
                      "flex items-center justify-center"
                    )}
                    disabled={branches.length <= 1}
                    onClick={goToPrevious}
                    transition={
                      shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY
                    }
                    type="button"
                    whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
                    whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
                  >
                    <ChevronLeftIcon size={12} />
                  </motion.button>

                  <span className="font-medium text-foreground/70 text-xs tabular-nums">
                    {currentBranchIndex + 1}/{branches.length}
                  </span>

                  <motion.button
                    aria-label="Next branch"
                    className={cn(
                      "size-6 shrink-0 cursor-pointer rounded text-foreground/70 transition-colors",
                      "hover:bg-muted hover:text-foreground",
                      "disabled:pointer-events-none disabled:opacity-50",
                      "flex items-center justify-center"
                    )}
                    disabled={branches.length <= 1}
                    onClick={goToNext}
                    transition={
                      shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY
                    }
                    type="button"
                    whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
                    whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
                  >
                    <ChevronRightIcon size={12} />
                  </motion.button>
                </div>
              )}
            </div>
          </div>

          {/* AI Response */}
          <div className="flex justify-start">
            <div className="max-w-[80%] rounded-2xl rounded-bl-md bg-muted px-3.5 py-2.5">
              <p className="text-foreground text-sm leading-relaxed">
                {activeBranch.aiResponse}
              </p>
            </div>
          </div>
        </motion.div>
      ) : null}
    </div>
  );
}

// Export the legacy component as the default for backward compatibility
export { LegacyAiBranch as AiBranch };

// Add default export for lazy loading
export default LegacyAiBranch;

Similar components