BottomSheetCustom
BottomSheetCustom — низкоуровневая версия BottomSheet, которая не диктует структуру содержимого. Потребитель сам компонует шапку, тело и футер из субкомпонентов BottomSheetCustom.Header, .Body, .Footer или собственной разметки.
Сам компонент берёт на себя portal, backdrop, slide-up-motion, focus-trap и swipe / snap-движок. Готовая анатомия (media-блок, dividers, авто-рендер back-кнопки) есть только у высокоуровневого BottomSheet.
Когда использовать
- Стандартной шапки / футера из
BottomSheetнедостаточно — нужна своя разметка. - Сложная раскладка нескольких секций внутри одного sheet’а.
- Кастомные слоты (например, фиксированный поиск между шапкой и телом).
Во всех остальных случаях предпочтительнее BottomSheet — он дешевле в поддержке и даёт консистентные отступы.
Анатомия
Header
Слот BottomSheetCustom.Header — title, slotAfterTitle, subtitle, slotSecondTitle, onBackButtonClick, actionButton.
Accessible name. В отличие от высокоуровневого
BottomSheet, низкоуровневыйBottomSheetCustomне связываетHeader.titleс dialog’ом автоматически — задайте имя сами:aria-label(илиaria-labelledbyна узел заголовка) прямо наBottomSheetCustom. Без него screen reader озвучит просто «dialog».
Non-modal / dismissal
showBackdrop={false} + lockScroll={false} дают non-modal sheet — фон не затемнён и остаётся прокручиваемым (sheet поверх живого контента). closeOnPopstate (default true) закрывает sheet по browser-back на mobile.
Body
Слот BottomSheetCustom.Body — основное содержимое (через children или content). Скроллится независимо от sheet’а.
Footer
Слот BottomSheetCustom.Footer — нижняя action-зона (обычно Button или их композиция).
Snap points (default undefined → height auto)
snapPoints принимает массив фиксированных позиций (number ∈ (0, 1] | 'Npx' | 'N%' | 'Ndvh' | 'Nsvh' | 'Nlvh' | 'fit-content') от меньшей к большей. Активный snap управляется через defaultSnapIndex (uncontrolled) либо snapIndex + onSnapIndexChange (controlled). В controlled-режиме swipe вызывает onSnapIndexChange, но позицию не двигает — потребитель сам передаёт новое значение.
Установка
pnpm add @cloud-ru/ds-bottom-sheet
import { BottomSheetCustom } from '@cloud-ru/ds-bottom-sheet'
Примеры использования
Ручная композиция
import { BottomSheetCustom } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
/**
* BottomSheetCustom — низкоуровневая обёртка. Backdrop, scroll-lock, focus-trap и slide-up-motion
* даёт сам компонент; анатомию (header / media / body / footer и их порядок) потребитель
* собирает из namespace-слотов `BottomSheetCustom.Handle / .Header / .Media / .Body / .Footer`.
*/
export function CustomComposition() {
const [open, setOpen] = useState(false);
return (
<MobilePreview>
<Button label='Открыть Custom' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheetCustom open={open} onClose={() => setOpen(false)} aria-label='Custom composition'>
<BottomSheetCustom.Header title='Custom composition' slotAfterTitle={<span>NEW</span>} />
<BottomSheetCustom.Body>
<p>Свободный JSX внутри Body. Можно вставить любой контент между Header и Footer.</p>
</BottomSheetCustom.Body>
<BottomSheetCustom.Footer>
<Button fullWidth view='filled' appearance='primary' label='Готово' onClick={() => setOpen(false)} />
</BottomSheetCustom.Footer>
</BottomSheetCustom>
</MobilePreview>
);
}Snap points: половина → full
import { BottomSheetCustom } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
/**
* Custom-слой полностью управляет snap-движком. `snapPoints={[0.5, 1]}` открывает sheet на
* половину экрана; drag вверх (или контролируемый `snapIndex`) раскрывает до full-viewport.
* Активный snap отслеживается через `onSnapIndexChange`.
*/
export function CustomSnapPoints() {
const [open, setOpen] = useState(false);
const [snapIndex, setSnapIndex] = useState(0);
return (
<MobilePreview>
<Button label='Открыть expandable' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheetCustom
open={open}
onClose={() => setOpen(false)}
snapPoints={[0.5, 1]}
snapIndex={snapIndex}
onSnapIndexChange={setSnapIndex}
aria-label='Snap points sheet'
>
<BottomSheetCustom.Header title={snapIndex === 0 ? 'Половина экрана' : 'Full-screen'} />
<BottomSheetCustom.Body>
<p>Текущий snap-индекс: {snapIndex}. Потяните вверх, чтобы раскрыть.</p>
</BottomSheetCustom.Body>
<BottomSheetCustom.Footer>
<Button
fullWidth
view='filled'
appearance='primary'
label={snapIndex === 0 ? 'Раскрыть' : 'Свернуть'}
onClick={() => setSnapIndex(snapIndex === 0 ? 1 : 0)}
/>
</BottomSheetCustom.Footer>
</BottomSheetCustom>
</MobilePreview>
);
}Scrollable body
import { BottomSheetCustom } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
/**
* Длинный контент в `BottomSheetCustom.Body` скроллится независимо: drag-движок отдаёт жест
* нативному скроллу, пока тело не упёрлось в край, и только тогда перехватывает swipe-down sheet'а.
* Header и Footer остаются на месте.
*/
export function CustomScrollable() {
const [open, setOpen] = useState(false);
const rows = Array.from({ length: 30 }, (_, i) => i + 1);
return (
<MobilePreview>
<Button label='Открыть scrollable' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheetCustom open={open} onClose={() => setOpen(false)} snapPoints={['60dvh']} aria-label='Длинный список'>
<BottomSheetCustom.Header title='Длинный список' />
<BottomSheetCustom.Body>
{rows.map(n => (
<p key={n}>Строка №{n}</p>
))}
</BottomSheetCustom.Body>
<BottomSheetCustom.Footer>
<Button fullWidth view='filled' appearance='primary' label='Готово' onClick={() => setOpen(false)} />
</BottomSheetCustom.Footer>
</BottomSheetCustom>
</MobilePreview>
);
}Без анимации
import { BottomSheetCustom } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
/**
* `disableMotions` отключает slide-up / slide-down и анимации перехода между snap-точками:
* sheet появляется и исчезает мгновенно. Удобно для reduced-motion, тестов и сценариев,
* где анимация мешает.
*/
export function CustomDisableMotions() {
const [open, setOpen] = useState(false);
return (
<MobilePreview>
<Button label='Открыть без анимации' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheetCustom open={open} onClose={() => setOpen(false)} disableMotions aria-label='Sheet без анимации'>
<BottomSheetCustom.Header title='Без анимации' />
<BottomSheetCustom.Body>
<p>Открытие и закрытие мгновенные — slide-up / slide-down отключены через disableMotions.</p>
</BottomSheetCustom.Body>
<BottomSheetCustom.Footer>
<Button fullWidth view='filled' appearance='primary' label='Закрыть' onClick={() => setOpen(false)} />
</BottomSheetCustom.Footer>
</BottomSheetCustom>
</MobilePreview>
);
}Props
Types
BottomSheetCustomProps| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
children | string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefined | — | no | |
className | string | — | no | CSS-класс самого sheet-контейнера. |
closeOnPopstate | boolean | true | no | Закрывать sheet при `popstate` (browser-back на mobile). |
container | string | HTMLElement | — | no | Контейнер для портала. По дефолту — `body` либо контекст-провайдер `@cloud-ru/ds-portal-context`. |
data-test-id | string | — | no | |
defaultSnapIndex | number | 0 | no | Индекс snap'а, на котором sheet открывается по дефолту. Игнорируется при controlled `snapIndex`. |
disableMotions | boolean | false | no | Отключить анимации открытия / закрытия и перехода между snap-точками. |
lockScroll | boolean | true | no | Блокировать ли скролл фона на время открытия (`react-remove-scroll`). При `false` страница под sheet'ом остаётся прокручиваемой — для non-modal сценариев (sheet поверх контента, с которым продолжают взаимодействовать). Обычно используется вместе с `showBackdrop={false}`. |
onClose | () => void | — | yes | Колбэк закрытия (вызывается при click outside, Esc, swipe-down, browser-back). |
onSnapIndexChange | ((snapIndex: number) => void) | — | no | Callback изменения активного snap'а (пересечение swipe-границы или click по UI). Не вызывается при программной смене controlled `snapIndex`. |
open | boolean | — | yes | Управление состоянием показан / не показан. |
rootClassName | string | — | no | CSS-класс корневого элемента portal'а. |
safeArea | boolean | true | no | Резервировать ли место под iOS notch / home-indicator и Android nav-bar. Реализовано паддингом на `.content` через `env(safe-area-inset-*)`: на устройстве без выреза/индикатора (и на desktop) inset = 0, поэтому никакого «лишнего» отступа не появляется; на notched-устройстве — ровно нужный. Верхний отступ добавляется только когда sheet раскрыт на полный вьюпорт (его верх под notch). |
showBackdrop | boolean | true | no | Отображение тёмной подложки за sheet'ом. При `false` фон не затемняется и click-outside не закрывает sheet (нет backdrop-узла, по которому ловится клик). |
snapIndex | number | — | no | Controlled-индекс активного snap'а. Если задан, sheet всегда находится на этом snap'е; swipe-up/down вызывают `onSnapIndexChange`, но не меняют позицию сами — consumer должен передать новое значение. |
snapPoints | SnapPoint[] | — | no | Массив фиксированных позиций sheet'а от меньшей к большей. По дефолту `undefined` — sheet `height: auto` с одним snap'ом по высоте контента. Пример: `[0.5, 1]` — sheet открывается на половину экрана, drag вверх раскрывает до full-viewport; drag вниз ниже `0.5` ведёт к закрытию. Контракт массива (движок не сортирует и не дедуплицирует — порядок и различимость на стороне потребителя): - строго по возрастанию: индекс `0` — самая компактная позиция, последний — top / expanded; - значения должны резолвиться в различные высоты (`['50%', 0.5]` на типичном вьюпорте дадут одну высоту → дубль-индекс будет недостижим свайпом); - `'fit-content'` имеет смысл только как ЕДИНСТВЕННЫЙ snap (без `snapPoints`); внутри массива фиксированных позиций его «контентная» высота не определена. |
swipeEnabled | boolean | true | no | Включает swipe-down для закрытия / swipe-up для раскрытия на следующий snap-point. При `swipeEnabled=false` snap-point по-прежнему можно переключить через controlled `snapIndex` prop'ом. |
Types
BottomSheetCustomProps
SnapPoint
Storybook
Figma
Смотри также
- BottomSheet — высокоуровневая обёртка с готовой анатомией.
- Drawer — выезжающая боковая панель (desktop / мульти-position).
- Modal — модальное окно по центру.