Aksara UI

Bottom sheet

A modal sheet that slides up from the bottom of the screen.

import { BottomSheet } from '@aksornproject/aksara-ui'

Basic sheet

BottomSheet is a namespace of parts. Use Trigger to open it and Close to close it. Pass a Button through render to style either control. The popup contains the scrim, handle and sheet.

<BottomSheet.Root>
  <BottomSheet.Trigger render={<Button>Open filters</Button>} />
  <BottomSheet.Popup>
    <BottomSheet.Header title="Filters" subtitle="Choose a subject" />
    <BottomSheet.Body>Choose the filters to apply.</BottomSheet.Body>
    <BottomSheet.Footer>
      <BottomSheet.Close render={<Button size="lg">Apply</Button>} />
    </BottomSheet.Footer>
  </BottomSheet.Popup>
</BottomSheet.Root>

Heights

hug follows content up to 640/844 of the overlay height. capped and tall take 523/844 and 776/844 respectively. The body scrolls when needed.

<div className="flex gap-spacing-8">
  {(['hug', 'capped', 'tall'] as const).map((height) => (
    <BottomSheet.Root key={height}>
      <BottomSheet.Trigger render={<Button>{height}</Button>} />
      <BottomSheet.Popup height={height}>
        <BottomSheet.Header title={height} />
        <BottomSheet.Body>
          Content scrolls when it exceeds the available height.
        </BottomSheet.Body>
      </BottomSheet.Popup>
    </BottomSheet.Root>
  ))}
</div>

Use aria-label or a BottomSheet.Title to name a sheet without a header. Set padding="flush" for edge-to-edge content.

<BottomSheet.Root>
  <BottomSheet.Trigger render={<Button>Share</Button>} />
  <BottomSheet.Popup aria-label="Share options">
    <BottomSheet.Body padding="flush">
      <BottomSheet.Close render={<Button>Copy link</Button>} />
    </BottomSheet.Body>
  </BottomSheet.Popup>
</BottomSheet.Root>

Non-dismissible

A non-dismissible sheet ignores dragging, scrim presses and Escape. It must be controlled with open and onOpenChange. Close and the header close button still close it.

<BottomSheet.Root dismissible={false} open={open} onOpenChange={setOpen}>
  <BottomSheet.Trigger render={<Button>Open required sheet</Button>} />
  <BottomSheet.Popup>
    <BottomSheet.Header title="Confirm" showClose={false} />
    <BottomSheet.Body>Use the button to close this sheet.</BottomSheet.Body>
    <BottomSheet.Footer>
      <BottomSheet.Close render={<Button size="lg">Done</Button>} />
    </BottomSheet.Footer>
  </BottomSheet.Popup>
</BottomSheet.Root>

Container

Pass container to render inside a positioned element. Heights follow that element rather than the viewport.

<div
  ref={setFrame}
  className="relative h-[500px] w-full max-w-[390px] overflow-hidden bg-color-background-body"
>
  <BottomSheet.Root>
    <BottomSheet.Trigger render={<Button>Open in frame</Button>} />
    <BottomSheet.Popup container={frame}>
      <BottomSheet.Header title="Contained sheet" />
      <BottomSheet.Body>The sheet fills this frame.</BottomSheet.Body>
    </BottomSheet.Popup>
  </BottomSheet.Root>
</div>

Popups inside a sheet

Select, Menu, Tooltip and nested Dialog popups portal into the sheet's host. They receive clicks and focus. Escape closes the inner popup without closing the sheet. A nested Dialog normally covers the viewport, even inside a contained sheet.

<BottomSheet.Root>
  <BottomSheet.Trigger render={<Button>Open options</Button>} />
  <BottomSheet.Popup>
    <BottomSheet.Header title="Options" />
    <BottomSheet.Body>
      <Select.Root items={items}>
        <Select.Trigger label="Subject" />
        <Select.Portal>
          <Select.Positioner>
            <Select.Popup>
              <Select.List>
                {items.map((item) => (
                  <Select.Item key={item.value} value={item.value}>
                    {item.label}
                  </Select.Item>
                ))}
              </Select.List>
            </Select.Popup>
          </Select.Positioner>
        </Select.Portal>
      </Select.Root>
      <Menu.Root>
        <Menu.Trigger>Actions</Menu.Trigger>
        <Menu.Portal>
          <Menu.Positioner>
            <Menu.Popup>
              <Menu.Item>Save</Menu.Item>
            </Menu.Popup>
          </Menu.Positioner>
        </Menu.Portal>
      </Menu.Root>
    </BottomSheet.Body>
  </BottomSheet.Popup>
</BottomSheet.Root>

Anatomy

<BottomSheet.Root>
  <BottomSheet.Trigger render={<Button>Open</Button>} />
  <BottomSheet.Popup>
    <BottomSheet.Header title="Filters" />
    <BottomSheet.Body>Content</BottomSheet.Body>
    <BottomSheet.Footer>
      <BottomSheet.Close render={<Button>Done</Button>} />
    </BottomSheet.Footer>
  </BottomSheet.Popup>
</BottomSheet.Root>

The footer stacks children by default and preserves their JSX order. Use className to change the layout.

Accessibility

  • The header title names the sheet and its subtitle describes it. Without a header, provide a Title or an accessible label.
  • Focus moves to the popup on open and returns to the trigger on close. Pass a ref or function to initialFocus to focus another element.
  • The header close button is named "Close" by default. Pass closeLabel to translate it.
  • The sheet is modal. Use a non-dismissible sheet only when the task needs an explicit action.

API

BottomSheet.Root

Prop

Type

BottomSheet.Trigger

Prop

Type

BottomSheet.Close

Prop

Type

BottomSheet.Popup

Prop

Type

BottomSheet.Header

Prop

Type

BottomSheet.Title

Prop

Type

BottomSheet.Description

Prop

Type

BottomSheet.Body

Prop

Type

BottomSheet.Footer

BottomSheetFooterProps accepts div props and a forwarded ref. Its className merges last.

Edit on GitHub

On this page