Bottom Sheet
- Stable
- WCAG 2.2 evidence
- RTL
- Web · iOS · Android
A mobile sheet that slides up from the bottom edge for a short task or a set of options. Built on React Aria ModalOverlay + Modal + Dialog: a role="dialog" named by its
title; focus moves inside on open, Tab stays inside while the page is inert and does not scroll, Escape, the close
button or a tap on the scrim closes it, and focus returns to the trigger (WCAG 2.1.1, 2.1.2, 2.4.3, 4.1.2). The grabber resizes the sheet between half and full: drag it up or down (down from half height closes), or use it
as a button: press it (Enter, Space, click) to toggle, or press the up and down arrow keys. So dragging is never the
only way (2.5.7). Motion only runs when allowed (2.3.3). Consumer duties: title and action wording; pass the same platform to the actions you put in footer. On wide
screens use Drawer or Popover instead.
import { BottomSheet } from "@nexera-ui/react";- 6
- examples
- 20
- props
- 7
- live controls
- 3
- platforms
- 9
- WCAG criteria
- 3
- blocks use it
Try every prop. Copy the code.
Change the props and the code updates. Check light and dark, LTR and RTL, and phone width.
import { BottomSheet } from "@nexera-ui/react";
<BottomSheet />Examples 5
The same examples as Storybook, rendered live. Open Code to copy one.
Full
Figma Detent=Full.
import { BottomSheet, Button } from "@nexera-ui/react";
function Filters() {
return (
<fieldset className="m-0 flex flex-col gap-3 border-0 p-0">
<legend className="mb-2 p-0 text-component-label text-primary">Status</legend>
{["Pending", "Approved", "Declined"].map((status) => (
<label key={status} className="flex items-center gap-2 text-body-large text-primary">
<input type="checkbox" className="size-5" defaultChecked={status === "Pending"} />
{status}
</label>
))}
</fieldset>
);
}
<BottomSheet
title="Filter requests"
platform="ios"
trigger={<Button platform="ios">Filters</Button>}
footer={
<Button platform="ios" size="lg" slot="close">
Show 12 requests
</Button>
}
defaultOpen
defaultDetent="full"
>
{<Filters />}
</BottomSheet>;
Android
Figma Platform=Android: a larger grabber area, an H3 title and the 52 dp ghost close button.
import { BottomSheet, Button } from "@nexera-ui/react";
function Filters() {
return (
<fieldset className="m-0 flex flex-col gap-3 border-0 p-0">
<legend className="mb-2 p-0 text-component-label text-primary">Status</legend>
{["Pending", "Approved", "Declined"].map((status) => (
<label key={status} className="flex items-center gap-2 text-body-large text-primary">
<input type="checkbox" className="size-5" defaultChecked={status === "Pending"} />
{status}
</label>
))}
</fieldset>
);
}
<BottomSheet
title="Filter requests"
platform="android"
trigger={<Button platform="android">Filters</Button>}
footer={
<Button platform="android" size="lg" slot="close">
Show 12 requests
</Button>
}
defaultOpen
>
{<Filters />}
</BottomSheet>;
Web
Web: the iOS geometry with a shorter footer. Prefer Drawer or Popover on wide screens.
import { BottomSheet, Button } from "@nexera-ui/react";
function Filters() {
return (
<fieldset className="m-0 flex flex-col gap-3 border-0 p-0">
<legend className="mb-2 p-0 text-component-label text-primary">Status</legend>
{["Pending", "Approved", "Declined"].map((status) => (
<label key={status} className="flex items-center gap-2 text-body-large text-primary">
<input type="checkbox" className="size-5" defaultChecked={status === "Pending"} />
{status}
</label>
))}
</fieldset>
);
}
<BottomSheet
title="Filter requests"
platform="web"
trigger={<Button>Filters</Button>}
footer={
<Button size="lg" slot="close">
Show 12 requests
</Button>
}
defaultOpen
>
{<Filters />}
</BottomSheet>;
Long content
A long body scrolls (and joins the tab order) between the header and the footer.
import { BottomSheet, Button } from "@nexera-ui/react";
function Filters() {
return (
<fieldset className="m-0 flex flex-col gap-3 border-0 p-0">
<legend className="mb-2 p-0 text-component-label text-primary">Status</legend>
{["Pending", "Approved", "Declined"].map((status) => (
<label key={status} className="flex items-center gap-2 text-body-large text-primary">
<input type="checkbox" className="size-5" defaultChecked={status === "Pending"} />
{status}
</label>
))}
</fieldset>
);
}
<BottomSheet
title="Leave policy for the payroll and finance teams in December"
platform="ios"
trigger={<Button platform="ios">Filters</Button>}
footer={
<Button platform="ios" size="lg" slot="close">
Show 12 requests
</Button>
}
defaultOpen
>
{
<div className="flex flex-col gap-3">
{Array.from({ length: 16 }, (_, index) => (
<p key={index} className="m-0">
{index + 1}. Requests for the last two weeks of December must be sent before 15 November.
</p>
))}
</div>
}
</BottomSheet>;
Right to left
Right-to-left: the title starts on the right and the close button sits on the left.
import { BottomSheet, Button } from "@nexera-ui/react";
function Filters() {
return (
<fieldset className="m-0 flex flex-col gap-3 border-0 p-0">
<legend className="mb-2 p-0 text-component-label text-primary">Status</legend>
{["Pending", "Approved", "Declined"].map((status) => (
<label key={status} className="flex items-center gap-2 text-body-large text-primary">
<input type="checkbox" className="size-5" defaultChecked={status === "Pending"} />
{status}
</label>
))}
</fieldset>
);
}
<BottomSheet
title="تصفية الطلبات"
platform="ios"
trigger={<Button platform="ios">تصفية</Button>}
footer={
<Button platform="ios" size="lg" slot="close">
عرض 12 طلبًا
</Button>
}
defaultOpen
closeLabel="إغلاق"
expandLabel="توسيع"
collapseLabel="تصغير"
>
{<Filters />}
</BottomSheet>;
Props 20
Press "Try it" on a card to load that prop into the playground.
20 props shown
title*NexeraReactNodeTitle: a level 2 heading that names the dialog. It wraps instead of truncating.
childrenNexeraReactNodeThe body. It scrolls.
footerNexeraReactNodeActions stacked at full width at the bottom, for example
<Button size="lg" platform="ios" slot="close">Apply</Button>. Shown when provided.
detentNexera"half" | "full"Height, controlled. Use with onDetentChange.
defaultDetentNexera"half" | "full"Height on first render (uncontrolled).
onDetentChangeNexera(detent: BottomSheetDetent) => voidCalled when the grabber (press, arrow keys or a drag) changes the height.
platformNexera"web" | "ios" | "android"Platform look. Defaults to the NexeraProvider platform.
expandLabelNexerastringAccessible name of the grabber while the sheet is at half height. Translate it for your locale.
collapseLabelNexerastringAccessible name of the grabber while the sheet is at full height. Translate it for your locale.
closeLabelNexerastringAccessible name of the close button. Translate it for your locale.
isDismissableNexerabooleanWhether a tap on the scrim closes the sheet. Escape and the close button always close it unless
isKeyboardDismissDisabled.
idNexerastringId of the sheet element.
classNameNexerastringExtra classes for the sheet, merged last so they win over the defaults.
styleNexeraCSSPropertiesInline style for the sheet.
triggerNexeraReactElement<unknown, string | JSXElementConstructor<any>>The element that opens the overlay, usually a Nexera Button. It toggles the overlay when pressed and gets focus
back when the overlay closes. Leave it out to open the overlay from your own state (isOpen + onOpenChange).
isOpenNexerabooleanWhether the overlay is open (controlled). Use with onOpenChange.
defaultOpenNexerabooleanWhether the overlay is open on first render (uncontrolled).
onOpenChangeNexera(isOpen: boolean) => voidCalled when the overlay opens or closes (Escape, the close button, a slot="close" action, a click outside).
shouldCloseOnInteractOutsideReact Aria(element: Element) => booleanWhen user interacts with the argument element outside of the overlay ref, return true if onClose should be called. This gives you a chance to filter out interaction with elements that should not dismiss the overlay. By default, onClose will always be called on interaction outside the overlay ref.
isKeyboardDismissDisabledReact AriabooleanWhether pressing the escape key to close the modal should be disabled.
* Required. React Aria props shown are the ones most apps use; the component accepts the rest of its React Aria props too.
Accessible by default.
Built on React Aria, and covered by the WCAG 2.2 evidence generated on every build.
WCAG 2.2 evidence
6 direct · 3 supporting- 1.3.1Info and RelationshipsLevel A · tested directly
- 1.4.10ReflowLevel AA · supporting test
- 2.1.1KeyboardLevel A · tested directly
- 2.1.2No Keyboard TrapLevel A · tested directly
- 2.3.3Animation from InteractionsLevel AAA · supporting test
- 2.4.3Focus OrderLevel A · tested directly
- 2.5.7Dragging MovementsLevel AA · tested directly
- 2.5.8Target Size (Minimum)Level AA · supporting test
- 4.1.2Name, Role, ValueLevel A · tested directly
Fits any width.
Nexera components respond to the space they are given. Drag the corner of the frame, or pick a width.
Styling hooks
Pass className to add Tailwind classes (merged last). State is exposed as data attributes, so you can style it with variants like data-pressed:.
data-draggingdata-enteringdata-exitingdata-hovereddata-pressed
<BottomSheet className="data-dragging:opacity-90 shadow-sm" />Used in blocks
Related components
- ActionSheetAn iOS-style sheet of actions that slides up from the bottom, with a separate Cancel row.
- ActionSheetItemOne action in an `ActionSheet`.
- CommandPaletteA searchable list of pages, actions and people in a modal.
- DialogA short modal question that needs a decision before work continues, such as "Submit October payroll?".
- DrawerA side panel over the page for details, filters or a secondary task.
- ModalA modal window for a task that needs the user's full attention, such as a short form.
- PopoverA short, non-modal panel anchored to a trigger.
- TooltipA short description of a control, shown on hover and keyboard focus.