Navigation drawer

Navigation drawers let people switch between UI views on larger devices

Usage

Navigation drawers provide access to destinations and app functionality, such as switching accounts. They can either be permanently on-screen or opened and closed by a navigation menu icon.

Instalation

Run the following command:

npm i @radix-ui/react-dialog @radix-ui/react-accordion @radix-ui/react-slot

Copy and paste the following code into your project.

'use client'

import * as React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import * as NavigationDrawerPrimitive from '@radix-ui/react-dialog'
import { Slot, Slottable } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'

import { cn } from '@/lib/utils'
import { Divider } from '@/components/ui/divider'
import { Icon } from '@/components/ui/icon'

const NavigationDrawerRoot = NavigationDrawerPrimitive.Root

const NavigationDrawerTrigger = NavigationDrawerPrimitive.Trigger

const NavigationDrawerClose = NavigationDrawerPrimitive.Close

const NavigationDrawerPortal = NavigationDrawerPrimitive.Portal

interface NavigationContextI {
  active?: string
  setActive: (value?: string) => void

  prev: string | undefined
  next: string | undefined

  dir: 'back' | 'forward' | undefined

  activeGroup?: string
  setActiveGroup: (group?: string | undefined) => void
  schema: Schema
}

const NavigationContext = React.createContext<NavigationContextI>({
  active: undefined,
  setActive() {},
  dir: undefined,
  prev: undefined,
  next: undefined,
  activeGroup: undefined,
  setActiveGroup() {},
  schema: {},
})

type Schema = {
  [key: string]: {
    parent?: string
    children?: string[]
  }
}

function getAdjacentGroups(
  schema: Schema,
  groupId?: string
): { prev: string | undefined; next: string | undefined } {
  let prev
  let next

  if (groupId) {
    prev = schema[groupId]?.parent

    for (const id in schema) {
      if (schema[id]?.parent === groupId) {
        next = id
        break
      }
    }
  }

  return { prev, next }
}

function isParent(schema: Schema, value: string, active: string) {
  let currentParent

  for (const id in schema) {
    if (schema[id].children?.includes(active)) {
      currentParent = id
      break
    }
  }

  while (currentParent) {
    if (currentParent === value) {
      return true
    } else {
      currentParent = schema[currentParent]?.parent
    }
  }

  return false
}

const NavigationDrawerOverlay = React.forwardRef<
  React.ElementRef<typeof NavigationDrawerPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof NavigationDrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <NavigationDrawerPrimitive.Overlay
    className={cn(
      'fixed inset-0 z-50 bg-palette-neutralVariant-20/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
      className
    )}
    {...props}
    ref={ref}
  />
))
NavigationDrawerOverlay.displayName =
  NavigationDrawerPrimitive.Overlay.displayName

const navigationDrawerVariants = cva(
  'fixed z-50 flex flex-col rounded-r-lg overflow-x-hidden bg-surfaceContainerLow p-3 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
  {
    variants: {
      side: {
        left: 'inset-y-0 left-0 w-4/5 h-full data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
        right:
          'inset-y-0 right-0 h-full w-4/5 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
      },
    },
    defaultVariants: {
      side: 'right',
    },
  }
)

interface NavigationDrawerContentProps
  extends React.ComponentPropsWithoutRef<
      typeof NavigationDrawerPrimitive.Content
    >,
    VariantProps<typeof navigationDrawerVariants> {
  value?: string
  schema: Schema
}

const NavigationDrawerContent = React.forwardRef<
  React.ElementRef<typeof NavigationDrawerPrimitive.Content>,
  NavigationDrawerContentProps
>(({ side = 'left', schema, className, value, children, ...props }, ref) => {
  const [active, setActive] = React.useState(value)
  const [activeGroup, setActiveGroup] = React.useState<string>()
  const [dir, setDir] = React.useState<'back' | 'forward' | undefined>()
  const groupRef = React.useRef<string>()

  const { prev, next } = React.useCallback(() => {
    return getAdjacentGroups(schema, activeGroup)
  }, [activeGroup])()

  React.useEffect(() => {
    if (groupRef.current === prev) {
      setDir('forward')
    } else {
      setDir('back')
    }
    groupRef.current = activeGroup
  }, [activeGroup])

  return (
    <NavigationContext.Provider
      value={{
        active,
        setActive,
        prev,
        next,
        dir,
        schema,
        activeGroup,
        setActiveGroup,
      }}
    >
      <NavigationDrawerPortal>
        <NavigationDrawerOverlay />
        <NavigationDrawerPrimitive.Content
          ref={ref}
          className={cn(navigationDrawerVariants({ side }), className)}
          {...props}
        >
          {children}
        </NavigationDrawerPrimitive.Content>
      </NavigationDrawerPortal>
    </NavigationContext.Provider>
  )
})
NavigationDrawerContent.displayName =
  NavigationDrawerPrimitive.Content.displayName

const NavigationDrawerHeader = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <header
    ref={ref}
    className={cn('mb-3 flex flex-col space-y-2 px-4', className)}
    {...props}
  />
))
NavigationDrawerHeader.displayName = 'NavigationDrawerHeader'

const NavigationDrawerFooter = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <footer
    ref={ref}
    className={cn('mb-3 mt-auto flex flex-col px-4', className)}
    {...props}
  />
))
NavigationDrawerFooter.displayName = 'NavigationDrawerFooter'

const NavigationDrawerHeadline = React.forwardRef<
  React.ElementRef<typeof NavigationDrawerPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof NavigationDrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
  <NavigationDrawerPrimitive.Title
    ref={ref}
    className={cn('text-title-sm text-onSurfaceVariant', className)}
    {...props}
  />
))
NavigationDrawerHeadline.displayName =
  NavigationDrawerPrimitive.Title.displayName

const NavigationDrawerGroup = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const { activeGroup } = React.useContext(NavigationContext)

  if (activeGroup) return null

  return (
    <nav
      ref={ref}
      className={cn(
        'flex w-full flex-col duration-300 animate-in fade-in-0 slide-in-from-left-5',
        className
      )}
      {...props}
    />
  )
})
NavigationDrawerGroup.displayName = 'NavigationDrawerGroup'

const NavigationDrawerItem = React.forwardRef<
  HTMLButtonElement,
  React.HTMLAttributes<HTMLButtonElement> & {
    id?: string
    asChild?: boolean
  }
>(({ className, id, children, asChild, ...props }, ref) => {
  const { active } = React.useContext(NavigationContext)
  const Comp = asChild ? Slot : 'button'
  const isActive = active === id

  return (
    <Comp
      ref={ref}
      id={id}
      data-active={isActive ? '' : undefined}
      className={cn(
        'group/item relative z-0 flex h-14 w-full cursor-pointer items-center justify-start gap-4 rounded-2xl px-4 py-0.5 text-onSurfaceVariant outline-none transition-colors focus:outline-none [&>i]:hover:font-emphasis',
        isActive &&
          'bg-secondaryContainer text-onSecondaryContainer [&>i]:font-filled [&>i]:hover:font-filled-emphasis',
        className
      )}
      {...props}
    >
      <Slottable>{children}</Slottable>
      <span className="absolute inset-0 z-0 rounded-2xl bg-onSurfaceVariant opacity-0 transition-opacity group-hover/item:opacity-8 group-focus/item:opacity-12 group-active/item:opacity-12" />
    </Comp>
  )
})
NavigationDrawerItem.displayName = 'NavigationDrawerItem'

const NavigationDrawerDivider = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn('my-4 px-4', className)} {...props}>
    <Divider />
  </div>
))

/**
 * Sub Group
 */

const NavigationDrawerSubGroup = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement> & {
    value: string
  }
>(({ className, value, ...props }, ref) => {
  const { activeGroup, dir } = React.useContext(NavigationContext)

  if ((!activeGroup && value) || (activeGroup && activeGroup !== value))
    return null

  return (
    <nav
      ref={ref}
      className={cn(
        'flex w-full flex-col duration-300 animate-in fade-in-0',
        dir === 'forward' ? 'slide-in-from-right-5' : 'slide-in-from-left-5',
        className
      )}
      {...props}
    />
  )
})
NavigationDrawerSubGroup.displayName = 'NavigationDrawerSubGroup'

const NavigationDrawerSubGroupTrigger = React.forwardRef<
  HTMLButtonElement,
  React.HTMLAttributes<HTMLButtonElement> & {
    value: string
  }
>(({ className, children, value, ...props }, ref) => {
  const { active, setActiveGroup, schema } = React.useContext(NavigationContext)
  const isActive =
    active && (active === value || isParent(schema, value, active))

  return (
    <button
      ref={ref}
      className={cn(
        'group/item relative z-0 flex h-14 w-full cursor-pointer items-center justify-start gap-4 rounded-2xl px-4 py-0.5 text-onSurfaceVariant outline-none transition-colors focus:outline-none [&>i]:hover:font-emphasis',
        isActive &&
          'bg-secondaryContainer text-onSecondaryContainer [&>i]:font-filled [&>i]:hover:font-filled-emphasis',
        className
      )}
      onClick={() => {
        setActiveGroup(value)
      }}
      {...props}
    >
      {children}
      <Icon
        symbol="arrow_forward"
        className="ml-auto size-6 group-data-[active]/item:text-onSecondaryContainer"
      />
      <span className="absolute inset-0 z-0 rounded-2xl bg-onSurfaceVariant opacity-0 transition-opacity group-hover/item:opacity-8 group-focus/item:opacity-12 group-active/item:opacity-12" />
    </button>
  )
})

const NavigationDrawerBackTrigger = React.forwardRef<
  HTMLButtonElement,
  React.HTMLAttributes<HTMLButtonElement>
>(({ className, children, ...props }, ref) => {
  const { prev, setActiveGroup } = React.useContext(NavigationContext)

  return (
    <button
      ref={ref}
      className={cn(
        'group/item relative z-0 flex h-14 w-full cursor-pointer items-center justify-start gap-4 rounded-2xl px-4 py-0.5 text-onSurfaceVariant outline-none transition-colors focus:outline-none [&>i]:hover:font-emphasis',
        className
      )}
      onClick={() => {
        setActiveGroup(prev)
      }}
      {...props}
    >
      <Icon
        symbol="arrow_back"
        className="size-6 group-data-[active]/item:text-onSecondaryContainer"
      />
      {children}
      <span className="absolute inset-0 z-0 rounded-2xl bg-onSurfaceVariant opacity-0 transition-opacity group-hover/item:opacity-8 group-focus/item:opacity-12 group-active/item:opacity-12" />
    </button>
  )
})

/**
 * Expandable Item
 */

const NavigationDrawerExpandableRoot = AccordionPrimitive.Root
NavigationDrawerExpandableRoot.displayName = 'NavigationDrawerExpandableRoot'

const NavigationDrawerExpandableItem = React.forwardRef<
  React.ElementRef<typeof AccordionPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
  <AccordionPrimitive.Item ref={ref} {...props} />
))
NavigationDrawerExpandableItem.displayName = 'NavigationDrawerExpandableItem'

const NavigationDrawerExpandableTrigger = React.forwardRef<
  React.ElementRef<typeof NavigationDrawerItem>,
  React.ComponentPropsWithoutRef<typeof NavigationDrawerItem>
>(({ className, children, id, ...props }, ref) => (
  <AccordionPrimitive.Trigger
    asChild
    className="group flex w-full flex-1 items-center justify-between py-4 font-medium text-onSurface transition-all"
  >
    <NavigationDrawerItem
      ref={ref}
      className={cn('flex w-full items-center justify-between', className)}
      {...props}
    >
      {children}
      <Icon
        symbol="expand_more"
        className="shrink-0 text-outline transition-[transform,font-variation-settings] duration-200 group-hover:font-emphasis group-data-[state=open]:rotate-180"
      />
    </NavigationDrawerItem>
  </AccordionPrimitive.Trigger>
))
NavigationDrawerExpandableTrigger.displayName =
  'NavigationDrawerExpandableTrigger'

const NavigationDrawerExpandableContent = React.forwardRef<
  React.ElementRef<typeof AccordionPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <AccordionPrimitive.Content
    ref={ref}
    className={cn(
      'w-full overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
      className
    )}
    {...props}
  >
    <div className="pb-4 pt-0">{children}</div>
  </AccordionPrimitive.Content>
))
NavigationDrawerExpandableContent.displayName =
  'NavigationDrawerExpandableContent'

const NavigationDrawer = Object.assign(NavigationDrawerRoot, {
  Portal: NavigationDrawerPortal,
  Overlay: NavigationDrawerOverlay,
  Trigger: NavigationDrawerTrigger,
  Content: NavigationDrawerContent,
  Header: NavigationDrawerHeader,
  Footer: NavigationDrawerFooter,
  Headline: NavigationDrawerHeadline,
  Close: NavigationDrawerClose,
  Group: NavigationDrawerGroup,
  Item: NavigationDrawerItem,
  Divider: NavigationDrawerDivider,
  SubGroup: NavigationDrawerSubGroup,
  SubGroupTrigger: NavigationDrawerSubGroupTrigger,
  Back: NavigationDrawerBackTrigger,
  ExpandableRoot: NavigationDrawerExpandableRoot,
  ExpandableItem: NavigationDrawerExpandableItem,
  ExpandableTrigger: NavigationDrawerExpandableTrigger,
  ExpandableContent: NavigationDrawerExpandableContent,
})

export {
  NavigationDrawer,
  NavigationDrawerRoot,
  NavigationDrawerPortal,
  NavigationDrawerOverlay,
  NavigationDrawerTrigger,
  NavigationDrawerClose,
  NavigationDrawerContent,
  NavigationDrawerHeader,
  NavigationDrawerFooter,
  NavigationDrawerHeadline,
}

Update the import paths to match your project setup.

Examples