Viana Kitv0.1.4

Components

Sidebar

A composable, collapsible navigation sidebar with built-in brand header and mobile support.

example.tsx
Main content

Use the trigger or drag the rail to collapse to icons.


Import

tsx
import {
  AppSidebarProvider,
  AppSidebar,
  AppSidebarBrand,
  AppSidebarTrigger,
  AppSidebarRail,
  AppSidebarInset,
  AppSidebarHeader,
  AppSidebarContent,
  AppSidebarFooter,
  AppSidebarSeparator,
  AppSidebarInput,
  AppSidebarGroup,
  AppSidebarGroupLabel,
  AppSidebarGroupAction,
  AppSidebarGroupContent,
  AppSidebarMenu,
  AppSidebarMenuItem,
  AppSidebarMenuButton,
  AppSidebarMenuAction,
  AppSidebarMenuBadge,
  AppSidebarMenuSkeleton,
  AppSidebarMenuSub,
  AppSidebarMenuSubItem,
  AppSidebarMenuSubButton,
  useSidebar,
} from "@/components/primitives/AppSidebar"

API Reference

AppSidebarProvider

Wraps the entire layout. Manages open/collapsed state and provides context to all child sidebar components. Open state is persisted to a cookie automatically.

PropTypeDefaultDescription
defaultOpenbooleantrueInitial open state (uncontrolled).
openbooleanControlled open state.
onOpenChange(open: boolean) => voidCallback fired when open state changes.

AppSidebar

PropTypeDefaultDescription
side"left" | "right""left"Which edge the sidebar is anchored to.
variant"sidebar" | "floating" | "inset""sidebar"Visual presentation style.
collapsible"offcanvas" | "icon" | "none""offcanvas"offcanvas: slides off-screen. icon: collapses to icon width. none: always visible.

AppSidebarBrand

Place as the first child of AppSidebarHeader. The logo is always horizontally centered. When a dropdown is provided, the chevron is absolutely positioned to the right edge so it does not shift the logo off-center. Accepts either a text string or a logo component for the logo prop.

PropTypeDefaultDescription
logostring | ReactNode<WhiteLogo width={90} height={28} />Text label or logo component. Defaults to the white Viana Kit logo for use on dark backgrounds.
collapsedLogoReactNode<WhiteSymbol width={24} height={24} />Icon shown in the header when the sidebar is collapsed to icon mode.
dropdownAppSidebarBrandDropdownItem[]Structured item list. When omitted the brand renders as a plain non-interactive element.
showChevronbooleantrueShow the caret icon. Only applies when dropdown is provided.
classNamestringAdditional classes for the brand container.

AppSidebarBrandDropdownItem

Two shapes are accepted in the same array — regular items and separators:

ts
// Regular item
{ label: string; onClick?: () => void; icon?: ReactNode; disabled?: boolean }

// Separator — renders a visual divider
{ separator: true }

AppSidebarMenuButton

No background fill in the default or hover state. The primary brand color background is applied only when isActive={true}. Do not add manual bg-* or hover:bg-* classes — fill behaviour is intentionally controlled by isActive only.

PropTypeDefaultDescription
asChildbooleanfalseRender as child element (e.g. a router Link).
isActivebooleanfalseMarks this item as the active route. Applies primary brand color background + medium font weight.
variant"default" | "outline""default"Visual style.
size"default" | "sm" | "lg""default"Height and text size.
tooltipstring | TooltipContentPropsTooltip shown when sidebar is collapsed to icon mode.

Usage

Default logo

Omit the logo prop entirely to use the built-in white Viana Kit logo at 90×28, centered in the header.

tsx
<AppSidebarHeader>
  <AppSidebarBrand />
</AppSidebarHeader>

Text logo

tsx
<AppSidebarHeader>
  <AppSidebarBrand logo="My App" />
</AppSidebarHeader>

Image logo

Pass any ReactNode as the logo prop. 90×28 is the standard sidebar header size.

tsx
import { WhiteLogo } from "@/assets/logos"

<AppSidebarHeader>
  <AppSidebarBrand logo={<WhiteLogo width={90} height={28} />} />
</AppSidebarHeader>

With workspace dropdown

Pass a dropdown array to turn the brand into a dropdown trigger. The chevron is shown by default, positioned to the right so the logo stays centered. Use { separator: true } to insert a visual divider.

tsx
<AppSidebarBrand
  dropdown={[
    { label: "Acme Corp", onClick: () => switchWorkspace("acme") },
    { label: "Personal",  onClick: () => switchWorkspace("personal") },
    { separator: true },
    { label: "Create workspace", onClick: openCreateModal },
  ]}
/>

Dropdown with icons

tsx
import { UserIcon, CreditCardIcon, LogOutIcon } from "lucide-react"

<AppSidebarBrand
  dropdown={[
    { label: "Profile", icon: <UserIcon className="size-4" />, onClick: goToProfile },
    { label: "Billing", icon: <CreditCardIcon className="size-4" />, onClick: goToBilling },
    { separator: true },
    { label: "Log out", icon: <LogOutIcon className="size-4" />, onClick: logout },
  ]}
/>

Disable the chevron

Set showChevron={false} to hide the caret while keeping the dropdown fully functional. Useful when the logo itself carries enough affordance, or for a minimal header style.

tsx
<AppSidebarBrand dropdown={items} showChevron={false} />

Active state

Pass isActive to mark the current route. No background is shown for inactive or hovered items.

tsx
<AppSidebarMenuButton isActive>Dashboard</AppSidebarMenuButton>
<AppSidebarMenuButton>Projects</AppSidebarMenuButton>

Menu button as link

tsx
<AppSidebarMenuButton asChild isActive>
  <a href="/dashboard">Dashboard</a>
</AppSidebarMenuButton>

Collapsible sidebar with trigger

Add AppSidebarRail for a drag-to-resize handle and AppSidebarTrigger for a toggle button. The keyboard shortcut ⌘B is wired automatically.

tsx
<AppSidebarProvider>
  <AppSidebar collapsible="icon">
    <AppSidebarHeader>
      <AppSidebarBrand dropdown={workspaces} />
    </AppSidebarHeader>
    <AppSidebarContent>...</AppSidebarContent>
    <AppSidebarRail />
  </AppSidebar>
  <AppSidebarInset>
    <header className="flex items-center gap-2 border-b border-border px-4 py-3">
      <AppSidebarTrigger />
    </header>
    <main>Page content</main>
  </AppSidebarInset>
</AppSidebarProvider>

Containing sidebar inside a preview box

When embedding a sidebar inside a fixed-height container, collapsible="icon" uses position: fixed which breaks out of the parent. Add [contain:layout] to the wrapper to create a containing block.

tsx
<div className="relative h-96 overflow-hidden rounded-lg border [contain:layout]">
  <AppSidebarProvider className="min-h-0 h-full">
    <AppSidebar collapsible="icon">...</AppSidebar>
    <AppSidebarInset>...</AppSidebarInset>
  </AppSidebarProvider>
</div>

Source

src/components/primitives/AppSidebar.tsx
"use client"

import * as React from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "../../lib/utils"
import { WhiteLogo, WhiteSymbol } from "../../assets/logos"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "../ui/dropdown-menu"
import { Sidebar, SidebarProvider, useSidebar, /* ... */ } from "../ui/sidebar"

export type AppSidebarBrandDropdownItem =
  | { separator: true }
  | { label: string; onClick?: () => void; icon?: React.ReactNode; disabled?: boolean }

function AppSidebarBrand({
  logo = <WhiteLogo width={90} height={28} />,
  collapsedLogo = <WhiteSymbol width={24} height={24} />,
  dropdown,
  showChevron = true,
  className,
}) {
  const { state } = useSidebar()
  const isCollapsed = state === "collapsed"

  const inner = (
    <div className="relative flex w-full items-center justify-center py-1">
      {isCollapsed
        ? collapsedLogo
        : typeof logo === "string"
          ? <span className="text-sm font-semibold text-foreground">{logo}</span>
          : logo}
      {!isCollapsed && dropdown && showChevron && (
        <ChevronDown className="absolute right-0 size-4 shrink-0 text-muted-foreground" />
      )}
    </div>
  )
  if (!dropdown || isCollapsed) {
    return <div className={cn("flex items-center px-2", className)}>{inner}</div>
  }
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <button className={cn("flex w-full items-center rounded-md px-2 outline-none hover:bg-sidebar-accent ...", className)}>
          {inner}
        </button>
      </DropdownMenuTrigger>
      <DropdownMenuContent side="bottom" align="start" className="min-w-(--radix-dropdown-menu-trigger-width)">
        {dropdown.map((item, i) =>
          "separator" in item && item.separator
            ? <DropdownMenuSeparator key={i} />
            : <DropdownMenuItem key={i} disabled={item.disabled} onClick={item.onClick}>
                {item.icon}{item.label}
              </DropdownMenuItem>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

function AppSidebarMenuButton({ className, ...props }) {
  return (
    <SidebarMenuButton
      className={cn(
        // Remove fill on hover and CSS :active
        "hover:bg-transparent hover:text-sidebar-foreground",
        "active:bg-transparent active:text-sidebar-foreground",
        // data-active: matches ALL buttons in Tailwind v4 (attribute always present) — reset
        "data-active:bg-transparent data-active:font-normal data-active:text-sidebar-foreground",
        // Re-apply primary brand color only for isActive={true}
        "data-[active=true]:bg-primary data-[active=true]:font-medium data-[active=true]:text-primary-foreground",
        className
      )}
      {...props}
    />
  )
}

// All other App* wrappers are passthrough — see full source in the repo
export { AppSidebar, AppSidebarBrand, AppSidebarMenuButton, AppSidebarProvider, /* ... */ }