Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
.Button {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
height: 2.5rem;
padding: 0 0.875rem;
margin: 0;
outline: 0;
border: 1px solid var(--color-gray-200);
border-radius: 0.375rem;
background-color: var(--color-gray-50);
font-family: inherit;
font-size: 1rem;
font-weight: 400;
line-height: 1.5rem;
color: var(--color-gray-900);
user-select: none;

@media (hover: hover) {
&:hover {
background-color: var(--color-gray-100);
}
}

&:active {
background-color: var(--color-gray-100);
}

&:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: -1px;
}
}

.Backdrop {
--backdrop-opacity: 0.2;
position: fixed;
min-height: 100dvh;
inset: 0;
background-color: black;
opacity: calc(var(--backdrop-opacity) * (1 - var(--drawer-swipe-progress)));
transition-duration: 450ms;
transition-property: opacity;
transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1);

@media (prefers-color-scheme: dark) {
--backdrop-opacity: 0.7;
}

&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}

&[data-swiping] {
transition-duration: 0ms;
}

&[data-ending-style] {
transition-duration: calc(var(--drawer-swipe-strength) * 400ms);
}
}

.Viewport {
position: fixed;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
touch-action: none;
}

.Popup {
--bleed: 3rem;
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
max-height: calc(100dvh - var(--top-margin) + var(--bleed));
margin-bottom: calc(-1 * var(--bleed));
border-radius: 1rem 1rem 0 0;
outline: 1px solid var(--color-gray-200);
background-color: white;
color: var(--color-gray-900);
overflow: visible;
touch-action: none;
box-shadow:
0 -16px 48px rgb(0 0 0 / 0.12),
0 6px 18px rgb(0 0 0 / 0.06);
transform: translateY(var(--drawer-swipe-movement-y));
transition:
transform 450ms cubic-bezier(0.32, 0.72, 0, 1),
box-shadow 450ms cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;

&[data-swiping] {
user-select: none;
}

&[data-starting-style],
&[data-ending-style] {
transform: translateY(calc(100% - var(--bleed) + 2px));
box-shadow:
0 -16px 48px rgb(0 0 0 / 0),
0 6px 18px rgb(0 0 0 / 0);
}

&[data-ending-style] {
transition-duration: calc(var(--drawer-swipe-strength) * 400ms);
}
}

.Chrome {
flex-shrink: 0;
padding: 0.875rem 1.5rem 0.75rem;
border-bottom: 1px solid var(--color-gray-200);
touch-action: none;
user-select: none;
}

.Handle {
width: 3rem;
height: 0.25rem;
margin: 0 auto 0.625rem;
flex-shrink: 0;
border-radius: 9999px;
background-color: var(--color-gray-300);
}

.Title {
margin: 0;
font-size: 1.125rem;
line-height: 1.75rem;
font-weight: 700;
letter-spacing: -0.01em;
text-align: center;
}

.Description {
margin: 0.25rem auto 0;
max-width: 28rem;
font-size: 0.9375rem;
line-height: 1.5rem;
color: var(--color-gray-600);
text-align: center;
}

.Scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: auto;
padding: 1rem 1.5rem;
}

.List {
display: grid;
gap: 0.75rem;
width: 100%;
max-width: 350px;
margin: 0 auto;
}

.Card {
height: 3rem;
border: 1px solid var(--color-gray-200);
border-radius: 0.75rem;
background-color: var(--color-gray-100);
}

.Footer {
flex-shrink: 0;
border-top: 1px solid var(--color-gray-200);
background-color: white;
}

.FooterInner {
width: 100%;
max-width: 28rem;
margin: 0 auto;
padding: 1rem 1.5rem calc(1rem + env(safe-area-inset-bottom, 0px) + var(--bleed));
}

.Field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.FieldLabel {
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 600;
}

.Input {
width: 100%;
min-height: 2.75rem;
border: 1px solid var(--color-gray-200);
border-radius: 0.875rem;
background-color: white;
padding: 0.75rem 0.875rem;
font: inherit;
color: inherit;

&:focus {
outline: 2px solid var(--color-blue);
outline-offset: -1px;
}
}

.Actions {
display: flex;
justify-content: flex-end;
margin-top: 0.875rem;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import { Drawer } from '@base-ui/react/drawer';
import styles from './index.module.css';

const TOP_MARGIN_REM = 2;

export default function ExampleDrawerVirtualKeyboardAware() {
return (
<Drawer.Root>
<Drawer.Trigger className={styles.Button}>Open keyboard-aware drawer</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Backdrop className={styles.Backdrop} />
<Drawer.Viewport className={styles.Viewport}>
<Drawer.Popup
className={styles.Popup}
style={{ '--top-margin': `${TOP_MARGIN_REM}rem` } as React.CSSProperties}
>
<div className={styles.Chrome}>
<div className={styles.Handle} />
<Drawer.Title className={styles.Title}>Delivery checklist</Drawer.Title>
<Drawer.Description className={styles.Description}>
The list scrolls independently while the note field stays pinned to the bottom.
</Drawer.Description>
</div>

<Drawer.Content className={styles.Scroll}>
<div className={styles.List}>
{Array.from({ length: 16 }, (_, index) => (
<div aria-hidden className={styles.Card} key={index} />
))}
</div>
</Drawer.Content>

<div className={styles.Footer}>
<div className={styles.FooterInner}>
<label className={styles.Field}>
<span className={styles.FieldLabel}>Driver message</span>
<input
className={styles.Input}
placeholder="Leave gate code or drop-off instructions"
type="text"
/>
</label>

<div className={styles.Actions}>
<Drawer.Close className={styles.Button}>Close</Drawer.Close>
</div>
</div>
</div>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createDemoWithVariants } from 'docs/src/utils/createDemo';
import CssModules from './css-modules';
import Tailwind from './tailwind';

export const DemoDrawerVirtualKeyboardAware = createDemoWithVariants(import.meta.url, {
CssModules,
Tailwind,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';
import * as React from 'react';
import { Drawer } from '@base-ui/react/drawer';

const TOP_MARGIN_REM = 2;

export default function ExampleDrawerVirtualKeyboardAware() {
return (
<Drawer.Root>
<Drawer.Trigger className="flex h-10 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3.5 text-base font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-800 active:bg-gray-100">
Open keyboard-aware drawer
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Backdrop className="[--backdrop-opacity:0.2] dark:[--backdrop-opacity:0.7] fixed inset-0 min-h-dvh bg-black opacity-[calc(var(--backdrop-opacity)*(1-var(--drawer-swipe-progress)))] transition-opacity duration-[450ms] ease-[cubic-bezier(0.32,0.72,0,1)] data-[swiping]:duration-0 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[ending-style]:duration-[calc(var(--drawer-swipe-strength)*400ms)]" />
<Drawer.Viewport className="fixed inset-0 flex items-end justify-center touch-none">
<Drawer.Popup
className="relative flex w-full max-h-[calc(100dvh-var(--top-margin)+var(--bleed))] flex-col overflow-visible rounded-t-2xl bg-white text-gray-900 outline outline-1 outline-gray-200 touch-none shadow-[0_-16px_48px_rgb(0_0_0/0.12),0_6px_18px_rgb(0_0_0/0.06)] [--bleed:3rem] [margin-bottom:calc(-1*var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y))] transition-[transform,box-shadow] duration-[450ms] ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform data-[swiping]:select-none data-[ending-style]:[transform:translateY(calc(100%-var(--bleed)+2px))] data-[starting-style]:[transform:translateY(calc(100%-var(--bleed)+2px))] data-[starting-style]:shadow-[0_-16px_48px_rgb(0_0_0/0),0_6px_18px_rgb(0_0_0/0)] data-[ending-style]:shadow-[0_-16px_48px_rgb(0_0_0/0),0_6px_18px_rgb(0_0_0/0)] data-[ending-style]:duration-[calc(var(--drawer-swipe-strength)*400ms)]"
style={{ '--top-margin': `${TOP_MARGIN_REM}rem` } as React.CSSProperties}
>
<div className="shrink-0 border-b border-gray-200 px-6 pt-3.5 pb-3 touch-none select-none">
<div className="mx-auto mb-2.5 h-1 w-12 rounded-full bg-gray-300" />
<Drawer.Title className="m-0 text-center text-lg font-bold tracking-[-0.01em]">
Delivery checklist
</Drawer.Title>
<Drawer.Description className="mx-auto mt-1 max-w-[28rem] text-center text-[0.9375rem] leading-6 text-gray-600">
The list scrolls independently while the note field stays pinned to the bottom.
</Drawer.Description>
</div>

<Drawer.Content className="min-h-0 flex-1 overflow-y-auto overscroll-contain touch-auto px-6 py-4">
<div className="mx-auto grid w-full max-w-[350px] gap-3">
{Array.from({ length: 16 }, (_, index) => (
<div
aria-hidden
className="h-12 rounded-xl border border-gray-200 bg-gray-100"
key={index}
/>
))}
</div>
</Drawer.Content>

<div className="shrink-0 border-t border-gray-200 bg-white">
<div className="mx-auto w-full max-w-[28rem] px-6 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px)+var(--bleed))]">
<label className="flex flex-col gap-2">
<span className="text-sm leading-5 font-semibold">Driver message</span>
<input
className="min-h-11 w-full rounded-[0.875rem] border border-gray-200 bg-white px-3.5 py-3 text-inherit outline-0 focus:outline focus:outline-2 focus:-outline-offset-1 focus:outline-blue-800"
placeholder="Leave gate code or drop-off instructions"
type="text"
/>
</label>

<div className="mt-3.5 flex justify-end">
<Drawer.Close className="flex h-10 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3.5 text-base font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-blue-800 active:bg-gray-100">
Close
</Drawer.Close>
</div>
</div>
</div>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}
13 changes: 13 additions & 0 deletions docs/src/app/(docs)/react/components/drawer/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import { Drawer } from '@base-ui/react/drawer';

Drawer supports swipe gestures to dismiss. Set `swipeDirection` to control which direction dismisses the drawer. `<Drawer.Content>` allows text selection of its children without swipe interference when using a mouse pointer. Add `data-base-ui-swipe-ignore` to a descendant when you need to opt that element out of swipe dismissal for all input types.

Bottom-sheet drawers automatically reposition focused text inputs for software keyboards. Set `disableInputRepositioning` to keep the legacy browser-driven behavior.

## Examples

### State
Expand Down Expand Up @@ -98,6 +100,7 @@ Positioning is handled by your styles. `swipeDirection` defaults to `"down"` for
```

import { DemoDrawerPosition } from './demos/position';
import { DemoDrawerVirtualKeyboardAware } from './demos/virtual-keyboard-aware';

<DemoDrawerPosition />

Expand Down Expand Up @@ -138,6 +141,16 @@ import { DemoDrawerSnapPoints } from './demos/snap-points';

By default, the drawer can skip snap points when swiping quickly. Specify the `snapToSequentialPoints` prop to disable velocity-based skipping so the snap target is determined by drag distance (you can still drag past multiple points).

### Virtual keyboard aware

Bottom-sheet drawers automatically react to software keyboards. This pattern keeps the main list scrollable while a footer field stays pinned outside the scroll container. Set `disableInputRepositioning` on `<Drawer.Root>` if you want to opt back into the legacy browser-driven scrolling behavior instead.

```tsx title="Virtual keyboard aware drawer"
<Drawer.Root>{/* ... */}</Drawer.Root>
```

<DemoDrawerVirtualKeyboardAware />

### Indent effect

Scale the background down when any drawer opens by wrapping your app in `<Drawer.Provider>` and use `<Drawer.IndentBackground>` + `<Drawer.Indent>` at the top of your tree. Any `<Drawer.Root>` within the provider notifies it when it mounts, which activates the indent parts (they receive `[data-active]` state attributes).
Expand Down
Loading
Loading