Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsNavigation

Sidebar

Composable app sidebar that collapses to an icon rail with tooltips, slides an ember active chip between items and opens as a side sheet on mobile.

Preview

General
  • 12
  • 3
Workspace

Projects/Pebble launch

⌘B

Pebble launch

Due Nov 18 · 14 of 22 tasks done

Open

8

In review

3

Shipped

11

  • Draft launch emailInes
  • Record product walkthroughMalik
  • Final pricing reviewTomás
General
  • 12
  • 3

Inbox

Collapsed to icons
  • Malik OseiWalkthrough cut is ready9:41
  • Ines DuarteRe: launch email copy8:02
  • Tomás ReyPricing table, v3Mon

Install

Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.

npx shadcn@latest add https://pebbleui.com/r/sidebar.json

Props

PropTypeDefaultDescription
defaultOpenbooleantrueSidebarProvider: expanded on first render (uncontrolled).
open / onOpenChangeboolean / (open: boolean) => void—SidebarProvider: controlled desktop state.
shortcutstring | false"b"SidebarProvider: Ctrl/Cmd + key toggles the sidebar. false turns it off.
collapsible"icon" | "offcanvas" | "none""icon"Sidebar: collapse to an icon rail, off-canvas, or never. Mobile uses a sheet.
labelstring"Sidebar"Sidebar: accessible name of the navigation landmark.
isActivebooleanfalseSidebarMenuButton / SidebarMenuSubButton: draws the sliding chip and sets aria-current.
tooltipstring—SidebarMenuButton: label shown while collapsed to icons.
size"md" | "lg""md"SidebarMenuButton: lg for workspace switcher and user rows.
tone"default" | "accent""default"SidebarMenuBadge: accent counts stay visible as an ember dot when collapsed.
Build notes
- **Layout.** Unlike shadcn's fixed-position sidebar with a spacer, ours is an
  in-flow flex child, so it works inside a framed mock as well as a full page.
  The provider sets `--sidebar-width: 15rem` and `--sidebar-width-icon: 3rem`.
  The outer `div` animates `width` (320 ms, `ease-move`) with
  `overflow-hidden`; the inner `nav` keeps the full width for off-canvas so it
  clips rather than reflows, and narrows to the rail width in icon mode.
- **State through CSS.** The outer element carries `data-state` and
  `data-collapsible` (`"icon" | "offcanvas" | ""`) and the named group
  `group/sidebar`; every part restyles with
  `group-data-[collapsible=icon]/sidebar:*` so no part re-renders on toggle.
- **Labels.** Collapsed menu buttons become `size-8`; every child after the
  first non-indicator child (label, chevron) fades to opacity 0 via
  `[&>:not([data-slot=sidebar-active-indicator])~*]`, keeping it as the
  accessible name. Group labels slide under with `-mt-7` + fade.
- **Active chip.** A `motion.span` with a provider-unique `layoutId` (from
  `useId`) rendered inside the active button, `absolute inset-0 -z-10` on an
  `isolate` button: card surface, `ring-hairline`, `shadow-raised-xs`,
  `inset-shadow-highlight` (muted surface in dark), with a 3 px ember bar on
  its left edge. Spring 420/32/0.8. Top-level and sub buttons share the id so
  it travels into nested menus.
- **Badge.** Absolute at the right; when collapsed the accent badge becomes a
  6 px ember dot at the icon's top-right (`text-[0px]`, `size-1.5`), and plain
  counts hide.
- **Surfaces.** The sidebar has no chrome (it sits on `bg-background`); the
  inset is the raised card (`m-2 rounded-xl ring-hairline shadow-raised-sm
inset-shadow-highlight`). Hover is a 4.5% foreground tint so it reads in
  both themes.
- **Mobile.** `matchMedia("(max-width: 767px)")` in an effect (starts false,
  SSR-safe); on mobile `Sidebar` renders inside `SideSheet side="left"`.
- **Tooltips.** `TooltipProvider delayDuration={0}` in the provider;
  `TooltipContent hidden` unless collapsed on desktop.

<!-- PEBBLE-LICENSE -->
View source

registry/nav/sidebar/sidebar.tsx

"use client";

/* PEBBLE-LICENSE
Built by Pebble. Component: sidebar.

First-party license: MIT

The following first-party terms apply only to original Pebble material.

MIT License

Copyright (c) 2026 Praxys Solutions LLC (Pebble)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.

Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License

Copyright (c) 2023 shadcn

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */

import * as React from "react";
import { MotionConfig, motion } from "motion/react";
import { Slot } from "radix-ui";

import { cn } from "@/lib/utils";
import {
  SideSheet,
  SideSheetContent,
  SideSheetDescription,
  SideSheetTitle,
} from "@/registry/overlay/side-sheet/side-sheet";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/overlay/tooltip/tooltip";

// Pebble: the sidebar is the desk and the content is the paper. The rail sits
// on the page colour with no chrome of its own; SidebarInset is the raised
// white card beside it. The active item is a raised pebble chip with an ember
// edge, shared through one `layoutId`, so it slides between items (nested
// ones too) instead of cross-fading.
const spring = { type: "spring", stiffness: 420, damping: 32, mass: 0.8 } as const;

const MOBILE_QUERY = "(max-width: 767px)";

type SidebarState = "expanded" | "collapsed";

type SidebarContextValue = {
  state: SidebarState;
  open: boolean;
  setOpen: (open: boolean) => void;
  isMobile: boolean;
  openMobile: boolean;
  setOpenMobile: (open: boolean) => void;
  toggleSidebar: () => void;
  shortcut: string | false;
  indicatorId: string;
};

const SidebarContext = React.createContext<SidebarContextValue | null>(null);

export function useSidebar() {
  const context = React.useContext(SidebarContext);
  if (!context) throw new Error("useSidebar must be used within a SidebarProvider.");
  return context;
}

function useIsMobile() {
  // Starts false on the server and first client render, then follows the
  // media query, so the markup always hydrates as the desktop layout.
  const [mobile, setMobile] = React.useState(false);
  React.useEffect(() => {
    const query = window.matchMedia(MOBILE_QUERY);
    const update = () => setMobile(query.matches);
    update();
    query.addEventListener("change", update);
    return () => query.removeEventListener("change", update);
  }, []);
  return mobile;
}

export type SidebarProviderProps = React.ComponentProps<"div"> & {
  /** Initial expanded state when uncontrolled. */
  defaultOpen?: boolean;
  /** Controlled expanded state (desktop). */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Key that toggles the sidebar with Ctrl or Cmd. `false` turns it off. */
  shortcut?: string | false;
};

export function SidebarProvider({
  defaultOpen = true,
  open: openProp,
  onOpenChange,
  shortcut = "b",
  className,
  style,
  children,
  ...props
}: SidebarProviderProps) {
  const isMobile = useIsMobile();
  const [openMobile, setOpenMobile] = React.useState(false);
  const [inner, setInner] = React.useState(defaultOpen);
  const open = openProp ?? inner;
  const indicatorId = React.useId();

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setInner(next);
      onOpenChange?.(next);
    },
    [openProp, onOpenChange],
  );

  const toggleSidebar = React.useCallback(() => {
    if (isMobile) setOpenMobile((value) => !value);
    else setOpen(!open);
  }, [isMobile, open, setOpen]);

  React.useEffect(() => {
    if (!shortcut) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key.toLowerCase() !== shortcut.toLowerCase()) return;
      if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
      // Leave Cmd+B to rich-text editors (bold).
      const target = event.target instanceof Element ? event.target : null;
      const editable = target?.closest('[contenteditable]:not([contenteditable="false"])');
      if (event.defaultPrevented || editable) return;
      event.preventDefault();
      toggleSidebar();
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [shortcut, toggleSidebar]);

  const state: SidebarState = open ? "expanded" : "collapsed";
  const context = React.useMemo<SidebarContextValue>(
    () => ({
      state,
      open,
      setOpen,
      isMobile,
      openMobile,
      setOpenMobile,
      toggleSidebar,
      shortcut,
      indicatorId,
    }),
    [state, open, setOpen, isMobile, openMobile, toggleSidebar, shortcut, indicatorId],
  );

  return (
    <SidebarContext.Provider value={context}>
      <MotionConfig reducedMotion="user">
        <TooltipProvider delayDuration={0}>
          <div
            data-slot="sidebar-wrapper"
            style={
              {
                "--sidebar-width": "15rem",
                "--sidebar-width-icon": "3rem",
                ...style,
              } as React.CSSProperties
            }
            className={cn("group/sidebar-wrapper flex min-h-0 w-full bg-background", className)}
            {...props}
          >
            {children}
          </div>
        </TooltipProvider>
      </MotionConfig>
    </SidebarContext.Provider>
  );
}

export type SidebarProps = React.ComponentProps<"div"> & {
  /**
   * How the sidebar collapses on desktop: to an icon rail, fully off-canvas,
   * or not at all. On mobile it always opens as a left side sheet.
   */
  collapsible?: "icon" | "offcanvas" | "none";
  /** Accessible name for the navigation landmark. */
  label?: string;
};

export function Sidebar({
  collapsible = "icon",
  label = "Sidebar",
  className,
  children,
  ...props
}: SidebarProps) {
  const { state, isMobile, openMobile, setOpenMobile } = useSidebar();

  if (isMobile) {
    return (
      <SideSheet open={openMobile} onOpenChange={setOpenMobile}>
        <SideSheetContent
          side="left"
          data-slot="sidebar"
          data-mobile="true"
          showCloseButton={false}
          className="w-(--sidebar-width) gap-0 bg-background p-0"
        >
          <SideSheetTitle className="sr-only">{label}</SideSheetTitle>
          <SideSheetDescription className="sr-only">Site navigation</SideSheetDescription>
          <nav aria-label={label} className={cn("flex h-full flex-col", className)}>
            {children}
          </nav>
        </SideSheetContent>
      </SideSheet>
    );
  }

  const collapsed = collapsible !== "none" && state === "collapsed";
  return (
    <div
      data-slot="sidebar"
      data-state={state}
      data-collapsible={collapsed ? collapsible : ""}
      className={cn(
        "group/sidebar relative h-full shrink-0 overflow-hidden text-foreground",
        "w-(--sidebar-width) data-[collapsible=icon]:w-(--sidebar-width-icon) data-[collapsible=offcanvas]:w-0",
        "transition-[width] duration-(--motion-slow) ease-move motion-reduce:transition-none",
      )}
      {...props}
    >
      {/* Fixed-width inner panel: off-canvas clips it instead of reflowing it. */}
      <nav
        aria-label={label}
        className={cn(
          "flex h-full w-(--sidebar-width) flex-col group-data-[collapsible=icon]/sidebar:w-(--sidebar-width-icon)",
          "transition-[width] duration-(--motion-slow) ease-move motion-reduce:transition-none",
          className,
        )}
      >
        {children}
      </nav>
    </div>
  );
}

export function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
  return (
    <main
      data-slot="sidebar-inset"
      className={cn(
        // The paper: a raised card set into the desk with an even gutter.
        "relative m-2 flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl bg-card text-card-foreground",
        "shadow-raised-sm ring-1 inset-shadow-highlight ring-hairline",
        className,
      )}
      {...props}
    />
  );
}

export function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<"button">) {
  const { toggleSidebar, open, openMobile, isMobile, shortcut } = useSidebar();
  const keys = shortcut
    ? `Control+${shortcut.toUpperCase()} Meta+${shortcut.toUpperCase()}`
    : undefined;
  return (
    <button
      type="button"
      data-slot="sidebar-trigger"
      aria-label="Toggle sidebar"
      aria-expanded={isMobile ? openMobile : open}
      aria-keyshortcuts={keys}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) toggleSidebar();
      }}
      className={cn(
        "inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground",
        "transition-[background-color,color,transform] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
        "hover:bg-muted hover:text-foreground active:scale-[0.94] motion-reduce:active:scale-100",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card focus-visible:outline-none",
        className,
      )}
      {...props}
    >
      <svg
        viewBox="0 0 16 16"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden
        className="size-4"
      >
        <rect x="2" y="2.75" width="12" height="10.5" rx="2.25" />
        <path d="M6.25 2.75v10.5" />
        <path
          d="M3.9 6.5h.6M3.9 8.5h.6"
          className="transition-opacity duration-(--motion-base) group-data-[state=collapsed]/sidebar-wrapper:opacity-0"
        />
      </svg>
    </button>
  );
}

export function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sidebar-header"
      className={cn("flex flex-col gap-2 p-2", className)}
      {...props}
    />
  );
}

export function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sidebar-footer"
      className={cn("mt-auto flex flex-col gap-2 p-2", className)}
      {...props}
    />
  );
}

export function SidebarSeparator({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      role="separator"
      data-slot="sidebar-separator"
      className={cn("mx-3 h-px shrink-0 bg-border", className)}
      {...props}
    />
  );
}

export function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sidebar-content"
      className={cn(
        "flex min-h-0 flex-1 [scrollbar-width:thin] flex-col gap-1 overflow-x-hidden overflow-y-auto",
        "group-data-[collapsible=icon]/sidebar:overflow-y-hidden",
        className,
      )}
      {...props}
    />
  );
}

export function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      role="group"
      data-slot="sidebar-group"
      className={cn("relative flex w-full min-w-0 flex-col px-2 py-1.5", className)}
      {...props}
    />
  );
}

export function SidebarGroupLabel({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sidebar-group-label"
      className={cn(
        "flex h-7 shrink-0 items-center px-2 text-xs font-medium text-muted-foreground",
        // Collapsing slides the label up under the group above and fades it,
        // so the icons close ranks without a jump.
        "transition-[margin,opacity] duration-(--motion-slow) ease-move motion-reduce:transition-none",
        "group-data-[collapsible=icon]/sidebar:-mt-7 group-data-[collapsible=icon]/sidebar:opacity-0",
        className,
      )}
      {...props}
    />
  );
}

export function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
  return (
    <ul
      data-slot="sidebar-menu"
      className={cn("flex w-full min-w-0 flex-col gap-0.5", className)}
      {...props}
    />
  );
}

export function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
  return (
    <li
      data-slot="sidebar-menu-item"
      className={cn("group/menu-item relative", className)}
      {...props}
    />
  );
}

function ActiveIndicator({ sub = false }: { sub?: boolean }) {
  const { indicatorId } = useSidebar();
  return (
    <motion.span
      aria-hidden
      layoutId={`${indicatorId}-active`}
      transition={spring}
      data-slot="sidebar-active-indicator"
      className={cn(
        "absolute inset-0 -z-10 rounded-md bg-card shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline",
        "dark:bg-muted",
      )}
    >
      <span
        className={cn(
          "absolute top-1/2 left-0 h-3.5 w-[3px] -translate-x-px -translate-y-1/2 rounded-full bg-accent",
          sub && "h-3",
          "group-data-[collapsible=icon]/sidebar:h-3",
        )}
      />
    </motion.span>
  );
}

const buttonSizes = {
  md: "h-8 text-[13px]",
  lg: "h-11 text-[13px] group-data-[collapsible=icon]/sidebar:p-0",
} as const;

export type SidebarMenuButtonProps = React.ComponentProps<"button"> & {
  /** Marks the current page: draws the sliding chip and sets aria-current. */
  isActive?: boolean;
  /** Label shown in a tooltip while the sidebar is collapsed to icons. */
  tooltip?: string;
  size?: keyof typeof buttonSizes;
  asChild?: boolean;
};

const menuButtonBase = cn(
  "peer/menu-button relative isolate flex w-full min-w-0 cursor-pointer items-center gap-2.5 overflow-hidden rounded-md px-2 text-left outline-none select-none",
  "text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
  "transition-[background-color,color,transform] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
  "hover:bg-foreground/[0.045] hover:text-foreground active:scale-[0.98] motion-reduce:active:scale-100",
  "focus-visible:ring-2 focus-visible:ring-ring",
  "aria-[current=page]:font-medium aria-[current=page]:text-foreground aria-[current=page]:hover:bg-transparent",
  "disabled:pointer-events-none disabled:opacity-50",
);

export function SidebarMenuButton({
  isActive = false,
  tooltip,
  size = "md",
  asChild = false,
  className,
  children,
  ...props
}: SidebarMenuButtonProps) {
  const { state, isMobile } = useSidebar();
  const Comp = asChild ? Slot.Root : "button";
  // The tooltip only exists while labels are hidden. Kept controlled (rather
  // than merely hiding its content) so an expanded sidebar never opens one on
  // focus: Radix would still mount its screen-reader copy (role="tooltip"),
  // e.g. right after a menu on this button closes and focus returns.
  const [tipOpen, setTipOpen] = React.useState(false);
  const showTip = state === "collapsed" && !isMobile;
  const button = (
    <Comp
      data-slot="sidebar-menu-button"
      data-size={size}
      data-active={isActive || undefined}
      aria-current={isActive ? "page" : undefined}
      type={asChild ? undefined : "button"}
      className={cn(
        menuButtonBase,
        "group-data-[collapsible=icon]/sidebar:size-8 group-data-[collapsible=icon]/sidebar:gap-0 group-data-[collapsible=icon]/sidebar:px-2",
        // Everything after the icon (label, chevron) stays in the DOM, as it
        // is the accessible name, but fades out as the rail closes over it.
        "[&>:not([data-slot=sidebar-active-indicator])~*]:transition-opacity [&>:not([data-slot=sidebar-active-indicator])~*]:duration-(--motion-base)",
        "group-data-[collapsible=icon]/sidebar:[&>:not([data-slot=sidebar-active-indicator])~*]:opacity-0",
        buttonSizes[size],
        className,
      )}
      {...props}
    >
      {asChild ? (
        children
      ) : (
        <>
          {isActive && <ActiveIndicator />}
          {children}
        </>
      )}
    </Comp>
  );
  if (!tooltip) return button;
  return (
    <Tooltip open={showTip && tipOpen} onOpenChange={setTipOpen}>
      <TooltipTrigger asChild>{button}</TooltipTrigger>
      <TooltipContent side="right" align="center">
        {tooltip}
      </TooltipContent>
    </Tooltip>
  );
}

export type SidebarMenuBadgeProps = React.ComponentProps<"span"> & {
  /** Ember tint for unread or live counts. */
  tone?: "default" | "accent";
};

export function SidebarMenuBadge({
  tone = "default",
  className,
  children,
  ...props
}: SidebarMenuBadgeProps) {
  return (
    <span
      data-slot="sidebar-menu-badge"
      className={cn(
        "pointer-events-none absolute top-1/2 right-1.5 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full px-1.5 text-[11px] font-medium tabular-nums select-none",
        tone === "accent"
          ? "bg-accent/12 text-accent-strong ring-1 ring-accent/30"
          : "text-muted-foreground",
        // Collapsed: the count becomes a dot on the icon's shoulder.
        "group-data-[collapsible=icon]/sidebar:top-1 group-data-[collapsible=icon]/sidebar:right-1 group-data-[collapsible=icon]/sidebar:size-1.5 group-data-[collapsible=icon]/sidebar:min-w-0 group-data-[collapsible=icon]/sidebar:translate-y-0 group-data-[collapsible=icon]/sidebar:p-0 group-data-[collapsible=icon]/sidebar:text-[0px] group-data-[collapsible=icon]/sidebar:ring-0",
        tone === "accent"
          ? "group-data-[collapsible=icon]/sidebar:bg-accent"
          : "group-data-[collapsible=icon]/sidebar:hidden",
        className,
      )}
      {...props}
    >
      {children}
    </span>
  );
}

export function SidebarMenuAction({ className, ...props }: React.ComponentProps<"button">) {
  return (
    <button
      type="button"
      data-slot="sidebar-menu-action"
      className={cn(
        "absolute top-1/2 right-1 flex size-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none [&_svg]:size-3.5",
        "transition-[background-color,color,opacity] duration-(--motion-fast) hover:bg-foreground/[0.06] hover:text-foreground",
        "focus-visible:ring-2 focus-visible:ring-ring",
        "group-data-[collapsible=icon]/sidebar:hidden",
        className,
      )}
      {...props}
    />
  );
}

export function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
  return (
    <ul
      data-slot="sidebar-menu-sub"
      className={cn(
        // A hairline guide under the parent icon ties the children to it.
        "relative ml-4 flex min-w-0 flex-col gap-0.5 border-l border-border py-0.5 pl-2",
        "group-data-[collapsible=icon]/sidebar:hidden",
        className,
      )}
      {...props}
    />
  );
}

export function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
  return <li data-slot="sidebar-menu-sub-item" className={cn("relative", className)} {...props} />;
}

export type SidebarMenuSubButtonProps = React.ComponentProps<"button"> & {
  isActive?: boolean;
  asChild?: boolean;
};

export function SidebarMenuSubButton({
  isActive = false,
  asChild = false,
  className,
  children,
  ...props
}: SidebarMenuSubButtonProps) {
  const Comp = asChild ? Slot.Root : "button";
  return (
    <Comp
      data-slot="sidebar-menu-sub-button"
      data-active={isActive || undefined}
      aria-current={isActive ? "page" : undefined}
      type={asChild ? undefined : "button"}
      className={cn(menuButtonBase, "h-7 text-[13px]", className)}
      {...props}
    >
      {asChild ? (
        children
      ) : (
        <>
          {isActive && <ActiveIndicator sub />}
          {children}
        </>
      )}
    </Comp>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗