AI Sidebar

Slide-out AI assistant panel with provider, header / content / footer slots, and a standalone trigger.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/ai-sidebar.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

3 stories available:

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  createContext,
  forwardRef,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useState,
} from "react";

import { Bot, MessageSquarePlus, X } from "lucide-react";

import { cn } from "../../lib/utils";
import { Button } from "../button/button";

const DEFAULT_WIDTH = 400;
const MIN_WIDTH = 280;
const MAX_WIDTH = 720;

/**
 * Side of the viewport the sidebar attaches to.
 *
 * @public
 */
export type AISidebarPosition = "left" | "right";

/**
 * Localizable strings for {@link AISidebar} subcomponents.
 *
 * @public
 */
export type AISidebarLabels = {
  /** Aria-label for the close control. Defaults to `"Close assistant"`. */
  close?: string;
  /** Default heading text for {@link AISidebarTitle}. */
  defaultTitle?: string;
  /** Aria-label for the open trigger. Defaults to `"Open AI assistant"`. */
  open?: string;
};

const DEFAULT_LABELS = {
  close: "Close assistant",
  defaultTitle: "AI Assistant",
  open: "Open AI assistant",
} as const satisfies Required<AISidebarLabels>;

type AISidebarContextValue = {
  close: () => void;
  labels: Required<AISidebarLabels>;
  open: () => void;
  openState: boolean;
  position: AISidebarPosition;
  setOpen: (next: boolean) => void;
  toggle: () => void;
  width: number;
};

const NO_OP = (): void => {
  return;
};

const DEFAULT_CONTEXT: AISidebarContextValue = {
  close: NO_OP,
  labels: DEFAULT_LABELS,
  open: NO_OP,
  openState: false,
  position: "right",
  setOpen: NO_OP,
  toggle: NO_OP,
  width: DEFAULT_WIDTH,
};

const AISidebarContext = createContext<AISidebarContextValue>(DEFAULT_CONTEXT);

/**
 * Hook for reading sidebar state from anywhere inside an
 * {@link AISidebarProvider}.
 *
 * @public
 */
export function useAISidebar(): AISidebarContextValue {
  return useContext(AISidebarContext);
}

/**
 * Props for {@link AISidebarProvider}.
 *
 * @public
 */
export type AISidebarProviderProps = {
  children?: ReactNode;
  /** Initial open state when uncontrolled. Defaults to `false`. */
  defaultOpen?: boolean;
  /** Initial position. Defaults to `"right"`. */
  defaultPosition?: AISidebarPosition;
  /** Initial width in px. Defaults to `400`. */
  defaultWidth?: number;
  /** Localizable strings. */
  labels?: AISidebarLabels;
  /** Fires when the open state changes (controlled or uncontrolled). */
  onOpenChange?: (open: boolean) => void;
  /** Controlled open state. */
  open?: boolean;
};

function clampWidth(value: number): number {
  return Math.min(Math.max(value, MIN_WIDTH), MAX_WIDTH);
}

/**
 * Provider for the AI sidebar context. Wrap your app shell with this so
 * {@link AISidebar}, {@link AISidebarTrigger}, and {@link useAISidebar}
 * share the same state.
 *
 * @public
 */
export function AISidebarProvider({
  children,
  defaultOpen = false,
  defaultPosition = "right",
  defaultWidth = DEFAULT_WIDTH,
  labels,
  onOpenChange,
  open: controlledOpen,
}: AISidebarProviderProps): ReactNode {
  const resolvedLabels = useMemo(
    () => ({ ...DEFAULT_LABELS, ...labels }),
    [labels],
  );
  const [uncontrolled, setUncontrolled] = useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const openState = isControlled ? controlledOpen : uncontrolled;

  const setOpen = useCallback(
    (next: boolean) => {
      if (!isControlled) setUncontrolled(next);
      onOpenChange?.(next);
    },
    [isControlled, onOpenChange],
  );

  const open = useCallback(() => {
    setOpen(true);
  }, [setOpen]);
  const close = useCallback(() => {
    setOpen(false);
  }, [setOpen]);
  const toggle = useCallback(() => {
    setOpen(!openState);
  }, [openState, setOpen]);

  const value = useMemo<AISidebarContextValue>(
    () => ({
      close,
      labels: resolvedLabels,
      open,
      openState,
      position: defaultPosition,
      setOpen,
      toggle,
      width: clampWidth(defaultWidth),
    }),
    [
      close,
      defaultPosition,
      defaultWidth,
      open,
      openState,
      resolvedLabels,
      setOpen,
      toggle,
    ],
  );

  return (
    <AISidebarContext.Provider value={value}>
      {children}
    </AISidebarContext.Provider>
  );
}

/**
 * Props for {@link AISidebar}.
 *
 * @public
 */
export type AISidebarProps = {
  /** When true, pressing Escape closes the sidebar. Defaults to `true`. */
  closeOnEscape?: boolean;
} & ComponentPropsWithoutRef<"aside">;

function useEscapeToClose(
  enabled: boolean,
  isOpen: boolean,
  onClose: () => void,
): void {
  useEffect(() => {
    if (!enabled || !isOpen) return;
    const handler = (event: KeyboardEvent): void => {
      if (event.key === "Escape") onClose();
    };
    document.addEventListener("keydown", handler);

    return () => {
      document.removeEventListener("keydown", handler);
    };
  }, [enabled, isOpen, onClose]);
}

/**
 * Slide-out AI assistant panel anchored to the left or right edge. Renders
 * an `<aside role="complementary">` so screen readers announce it as a
 * complementary region. Sets `aria-hidden` on close so its content is
 * skipped by assistive tech.
 *
 * @example
 * ```tsx
 * <AISidebarProvider defaultOpen={false}>
 *   <AISidebar>
 *     <AISidebarHeader>
 *       <AISidebarTitle>AI Assistant</AISidebarTitle>
 *       <AISidebarClose />
 *     </AISidebarHeader>
 *     <AISidebarContent>{messages}</AISidebarContent>
 *     <AISidebarFooter>{composer}</AISidebarFooter>
 *   </AISidebar>
 *   <AISidebarTrigger />
 *   <main>{children}</main>
 * </AISidebarProvider>
 * ```
 *
 * @public
 */
export const AISidebar = forwardRef<HTMLElement, AISidebarProps>(
  (props, ref) => {
    const { children, className, closeOnEscape = true, ...rest } = props;
    const { close, labels, openState, position, width } = useAISidebar();
    useEscapeToClose(closeOnEscape, openState, close);

    return (
      <aside
        aria-hidden={!openState}
        aria-label={labels.defaultTitle}
        className={cn(
          "fixed top-0 z-40 flex h-full flex-col border-border bg-background shadow-lg transition-transform duration-200 ease-out",
          position === "right" ? "right-0 border-l" : "left-0 border-r",
          openState
            ? "translate-x-0"
            : position === "right"
              ? "translate-x-full"
              : "-translate-x-full",
          "max-w-full",
          className,
        )}
        data-state={openState ? "open" : "closed"}
        ref={ref}
        style={{ width: `${width.toString()}px` }}
        {...rest}
      >
        {children}
      </aside>
    );
  },
);
AISidebar.displayName = "AISidebar";

/**
 * Header slot for an {@link AISidebar}. Use to host the title, model
 * selector, and the close control.
 *
 * @public
 */
export const AISidebarHeader = forwardRef<
  HTMLElement,
  ComponentPropsWithoutRef<"header">
>(({ children, className, ...rest }, ref) => (
  <header
    className={cn(
      "flex items-center gap-2 border-b border-border px-4 py-3",
      className,
    )}
    ref={ref}
    {...rest}
  >
    {children}
  </header>
));
AISidebarHeader.displayName = "AISidebarHeader";

/**
 * Title slot for {@link AISidebarHeader}. Defaults to the localized title
 * from the provider whenever the consumer omits children.
 *
 * @public
 */
export const AISidebarTitle = forwardRef<
  HTMLHeadingElement,
  ComponentPropsWithoutRef<"h2">
>(({ children, className, ...rest }, ref) => {
  const { labels } = useAISidebar();
  return (
    <h2
      className={cn(
        "flex flex-1 items-center gap-2 text-sm font-semibold tracking-tight text-foreground",
        className,
      )}
      ref={ref}
      {...rest}
    >
      <Bot aria-hidden="true" className="size-4 text-muted-foreground" />
      {children ?? labels.defaultTitle}
    </h2>
  );
});
AISidebarTitle.displayName = "AISidebarTitle";

/**
 * Close-button slot for {@link AISidebarHeader}.
 *
 * @public
 */
export const AISidebarClose = forwardRef<
  HTMLButtonElement,
  Omit<ComponentPropsWithoutRef<"button">, "type">
>(({ className, onClick, ...rest }, ref) => {
  const { close, labels } = useAISidebar();
  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      onClick?.(event);
      if (event.defaultPrevented) return;
      close();
    },
    [close, onClick],
  );
  return (
    <Button
      aria-label={labels.close}
      className={cn("size-8", className)}
      onClick={handleClick}
      ref={ref}
      size="icon"
      type="button"
      variant="ghost"
      {...rest}
    >
      <X aria-hidden="true" className="size-4" />
    </Button>
  );
});
AISidebarClose.displayName = "AISidebarClose";

/**
 * Scrollable middle section of {@link AISidebar}.
 *
 * @public
 */
export const AISidebarContent = forwardRef<
  HTMLDivElement,
  ComponentPropsWithoutRef<"div">
>(({ children, className, ...rest }, ref) => (
  <div
    className={cn("flex flex-1 flex-col gap-2 overflow-y-auto p-4", className)}
    ref={ref}
    {...rest}
  >
    {children}
  </div>
));
AISidebarContent.displayName = "AISidebarContent";

/**
 * Bottom slot of {@link AISidebar}, typically the chat composer.
 *
 * @public
 */
export const AISidebarFooter = forwardRef<
  HTMLElement,
  ComponentPropsWithoutRef<"footer">
>(({ children, className, ...rest }, ref) => (
  <footer
    className={cn("border-t border-border bg-background px-4 py-3", className)}
    ref={ref}
    {...rest}
  >
    {children}
  </footer>
));
AISidebarFooter.displayName = "AISidebarFooter";

/**
 * Props for {@link AISidebarTrigger}.
 *
 * @public
 */
export type AISidebarTriggerProps = Omit<
  ComponentPropsWithoutRef<"button">,
  "type"
>;

/**
 * Standalone control that opens the sidebar. Place anywhere inside an
 * {@link AISidebarProvider}. Falls back to the default icon + label when
 * the consumer omits children.
 *
 * @public
 */
export const AISidebarTrigger = forwardRef<
  HTMLButtonElement,
  AISidebarTriggerProps
>(({ children, className, onClick, ...rest }, ref) => {
  const { labels, openState, toggle } = useAISidebar();
  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      onClick?.(event);
      if (event.defaultPrevented) return;
      toggle();
    },
    [onClick, toggle],
  );
  return (
    <Button
      aria-expanded={openState}
      aria-label={children ? undefined : labels.open}
      className={cn(className)}
      data-state={openState ? "open" : "closed"}
      onClick={handleClick}
      ref={ref}
      size={children ? "sm" : "icon"}
      type="button"
      variant="outline"
      {...rest}
    >
      {children ?? <MessageSquarePlus aria-hidden="true" className="size-4" />}
    </Button>
  );
});
AISidebarTrigger.displayName = "AISidebarTrigger";
typescript

Dependencies

  • @vllnt/ui@^0.2.1
  • lucide-react