BottomSheet
Mobile-first bottom-sheet — overlay-контейнер, выезжающий снизу. Используется как базовый layer для диалогов, выпадающих списков, фильтров и любых полу-полно-экранных UI на мобильных устройствах.
Когда использовать
- Полу-полно-экранный диалог на мобильном устройстве.
- Action-sheet («Выбрать действие»: фото, удалить, отменить).
- Multi-step flow с back-кнопкой в шапке.
- Контейнер для выпадающего списка / фильтров на мобильном.
Когда не нужен
- На desktop’е — используйте Modal или Drawer.
- Для коротких подтверждений на одну строку — Toaster.
- Для контента, который должен перекрыть весь экран без возможности dismiss — это новый screen, не bottom-sheet.
Анатомия
Handle
Drag-индикатор 32×4px сверху — визуальная подсказка для swipe-down. Показывается, когда включён свайп; при swipeEnabled={false} его нет (нет жеста — нет намёка на него).
Backdrop (default showBackdrop=true)
Полупрозрачная подложка. Click по backdrop’у вызывает onClose. При showBackdrop={false} фон не затемняется и click-outside не закрывает sheet.
Non-modal (default lockScroll=true)
По умолчанию sheet — модальный: фон затемнён и заблокирован для скролла. Для non-modal сценария (sheet поверх контента, с которым продолжают работать) передайте lockScroll={false} (страница под sheet’ом скроллится) — обычно вместе с showBackdrop={false}.
Header
title— заголовок.slotAfterTitle— slot справа от title (QuestionTooltip, badge).onBackButtonClick— авто-рендерит back-кнопку слева.actionButton— кастомный slot справа.subtitle— текстовая строка-подзаголовок под title.slotSecondTitle— slot под подзаголовком (SearchBar / SegmentControl).
Media
kind='image'— full-bleed image, min-height 184px; прижато к шапке (убирает верхний отступ контент-блока). Горизонтальные паддинги body не меняются — для edge-to-edge body передайтеbodyPadding={false}.kind='icon'— иконка сpadding-top: 24px.- Произвольный
ReactNode— если нужен свой media-блок (видео, карта, кастомная разметка).
Body padding (default bodyPadding=true)
Горизонтальные паддинги тела. При bodyPadding={false} контент идёт во всю ширину (edge-to-edge) —
для карт, изображений, списков без отступов. Соответствует Figma-оси padding=false.
Footer actions (default orientation horizontal)
Высокоуровневый footer собирается из объектов пропсов Button — кнопки рендерятся через ButtonGroup:
approveButton— основное действие (по умолчаниюview='filled',appearance='primary').cancelButton— отмена (по умолчаниюview='outline',appearance='neutral').additionalButton— третье действие (по умолчаниюview='simple',appearance='neutral').disclaimer— мелкий центрированный текст под кнопками.
footerActionsOrientation управляет раскладкой пары cancel/confirm:
'horizontal'— ряд через space-between (secondary слева, primary справа), ширина по контенту. Дефолт — точное соответствие FigmabottomBar.buttonGroup.'vertical'— кнопки в столбик, full-width.
Одна кнопка всегда рендерится full-width (одиночный CTA); три кнопки не помещаются в ряд на mobile-вьюпорте и всегда идут в столбик.
Для произвольной разметки — footer: ReactNode (имеет приоритет над approveButton / cancelButton
/ additionalButton / disclaimer).
Dividers (default withDividers=true)
Тонкие линии между topBar↔body и body↔footer. Разграничивают закреплённые шапку и подвал от
прокручиваемого под ними содержимого. Передайте withDividers={false}, чтобы убрать обе линии.
SafeArea (default safeArea=true)
Блоки сверху/снизу, резервирующие место под iOS notch / home-indicator и Android nav-bar через
env(safe-area-inset-*) (с 32px-фолбэком для embedded webview, который не отдаёт inset’ы). Figma-артборд
эти блоки скрывает; на реальном устройстве они нужны, чтобы футер не уходил под home-indicator. Передайте
safeArea={false}, если рамку safe-area обеспечивает окружение (например, нативная оболочка webview).
Snap points (default undefined → height auto)
По дефолту sheet height: auto (один snap по высоте контента). Когда задан snapPoints-массив, sheet поддерживает несколько фиксированных позиций (iOS-detents-аналог):
<BottomSheet
open={open}
onClose={onClose}
snapPoints={[0.5, 1]}
defaultSnapIndex={0}
title='Меню'
content={<List />}
/>
Формат — number ∈ (0, 1] | 'Npx' | 'N%' | 'Ndvh' | 'Nsvh' | 'Nlvh' | 'fit-content'. Порядок массива — от меньшей позиции к большей. Drag вверх → следующий snap; drag вниз ниже первого snap’а — закрытие.
- Controlled snap — задайте
snapIndex+onSnapIndexChange. В этом режиме swipe вызываетonSnapIndexChange, но позицию не двигает: потребитель сам передаёт новое значение обратно. swipeEnabled(defaulttrue) —falseотключает swipe-жесты; переключить snap можно только программно черезsnapIndex.closeOnPopstate(defaulttrue) — закрытие по browser-back (popstate). Полезно на mobile, чтобы аппаратная кнопка «назад» закрывала sheet вместо ухода со страницы.
Доступность
- Роль и имя. Sheet —
role='dialog'+aria-modal='true'. ВысокоуровневыйBottomSheetавтоматически связываетtitleс dialog’ом черезaria-labelledby(accessible name). Еслиtitleнет (icon-/media-only sheet) или вы используете низкоуровневыйBottomSheetCustom— задайтеaria-labelсами (прокидывается на dialog). - Клавиатура.
Escзакрывает sheet (верхний слой при вложенных). Фокус переносится внутрь при открытии, зациклен поTab/Shift+Tabи возвращается на триггер после закрытия. - Motion. Slide / fade / height-анимации гасятся при
prefers-reduced-motion: reduce. - Media. Для
mediaвсегда задавайте осмысленныйalt(он обязателен в типеBottomSheetMediaProps). - Non-modal. При
showBackdrop={false}+lockScroll={false}sheet немодальный:aria-modalне выставляется, фокус не запирается (Tab уходит на фон), Esc закрывает даже когда фокус снаружи. - Dismiss-контрол. Своей кнопки «закрыть» у sheet’а нет (dismiss — Esc / клик по backdrop / swipe-down). Если у sheet’а нет ни back-кнопки, ни footer-«Отмена», добавьте явный in-sheet dismiss для keyboard/SR-пользователей.
- Snap-точки и клавиатура. Переключение detent’ов (
snapPoints) — это pointer-жест (swipe); клавиатурой detent не меняется. Контент ниже фолда доступен через скролл body + Tab. Для клавиатурного управления detent’ом используйте controlledsnapIndexсо своим контролом. - Pinch-zoom. В модальном режиме фон-скролл лочится
react-remove-scroll— на iOS это также блокирует pinch-to-zoom, пока sheet открыт (DS-wide, как у Modal/Drawer).
Установка
pnpm add @cloud-ru/ds-bottom-sheet
import { BottomSheet, BottomSheetCustom, TEST_IDS } from '@cloud-ru/ds-bottom-sheet'
Примеры использования
Базовый сценарий
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
export function Basic() {
const [open, setOpen] = useState(false);
return (
<MobilePreview>
<Button label='Открыть BottomSheet' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Bottom-sheet headline'
content={
<p>
Bottom-sheet — мобильный overlay-контейнер, выезжающий снизу. Используйте его как базовый layer для
диалогов, выпадающих списков, фильтров и любых полу-полно-экранных UI.
</p>
}
approveButton={{ label: 'Подтвердить', onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}Кнопки футера + дисклеймер
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
export function FooterActions() {
const [open, setOpen] = useState(false);
return (
<MobilePreview>
<Button label='Удалить ресурс' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Удалить ресурс?'
content={<p>Действие необратимо. Все связанные данные будут удалены без возможности восстановления.</p>}
// Три действия: не помещаются в ряд на mobile-вьюпорте, поэтому собираются
// в вертикальный full-width ButtonGroup (primary сверху). Для пары cancel/confirm футер
// по умолчанию горизонтальный (space-between) — управляется `footerActionsOrientation`.
approveButton={{ label: 'Удалить', appearance: 'critical', onClick: () => setOpen(false) }}
cancelButton={{ label: 'Отмена', onClick: () => setOpen(false) }}
additionalButton={{ label: 'Подробнее', onClick: () => undefined }}
/>
</MobilePreview>
);
}С media-блоком
import { BottomSheet, MEDIA_KIND } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
export function WithMedia() {
const [open, setOpen] = useState(false);
return (
<MobilePreview>
<Button label='Открыть с media' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Bottom-sheet with media'
media={{
src: 'https://placehold.co/360x184?text=Media',
alt: 'Media',
kind: MEDIA_KIND.Image,
}}
content={<p>Media-блок full-bleed, прижат к шапке. bodyPadding управляет паддингами body отдельно.</p>}
approveButton={{ label: 'Подтвердить', onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}С subtitle
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
export function WithSubtitle() {
const [open, setOpen] = useState(false);
return (
<MobilePreview>
<Button label='Открыть с subtitle' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Filters'
// В продакшене сюда — `@cloud-ru/ds-search::SearchBar` или `@cloud-ru/ds-segment-control::SegmentControl`.
slotSecondTitle={<div>SearchBar / SegmentControl placeholder</div>}
content={<p>Subtitle располагается под заголовком — sticky-зона для поиска/фильтров.</p>}
approveButton={{ label: 'Применить', onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}С back- и action-кнопкой
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { Dropdown } from '@cloud-ru/ds-dropdown';
import { KebabSVG } from '@cloud-ru/ds-icons/interface/system';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
import styles from './styles.module.scss';
const MENU_ACTIONS = ['Поделиться', 'Дублировать', 'Удалить'];
/**
* `actionButton` — слот в правом верхнем углу header'а. Здесь это kebab-кнопка, открывающая
* Dropdown со списком действий; выбранное действие отражается в теле sheet'а. Back-button слева
* появляется автоматически по `onBackButtonClick`.
*/
export function WithActionButton() {
const [open, setOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [lastAction, setLastAction] = useState<string | null>(null);
return (
<MobilePreview>
<Button label='Открыть с действиями' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Detail view'
onBackButtonClick={() => setOpen(false)}
actionButton={
// TODO: заменить на дроплист
<Dropdown
open={menuOpen}
onOpenChange={setMenuOpen}
placement='bottom-end'
content={
<div className={styles.menu}>
{MENU_ACTIONS.map(action => (
<button
key={action}
type='button'
className={styles.menuItem}
onClick={() => {
setLastAction(action);
setMenuOpen(false);
}}
>
{action}
</button>
))}
</div>
}
>
<Button view='function' appearance='neutral' icon={<KebabSVG />} aria-label='Действия' />
</Dropdown>
}
content={
<p>
Кнопка-меню в правом верхнем углу открывает список действий.
{lastAction ? ` Выбрано: «${lastAction}».` : ''}
</p>
}
approveButton={{ label: 'Готово', onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}Scrollable + dividers
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
export function Scrollable() {
const [open, setOpen] = useState(false);
return (
<MobilePreview>
<Button label='Открыть scrollable' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Scrollable content'
withDividers
content={
<div>
{Array.from({ length: 30 }).map((_, i) => (
<p key={i}>Параграф {i + 1}. Body скроллится, header и footer остаются sticky.</p>
))}
</div>
}
approveButton={{ label: 'Закрыть', onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}Expandable: половина → full
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
/**
* Expandable bottom-sheet: открывается на первом snap'е, drag за handle вверх раскрывает на следующий,
* drag вниз ниже первого — закрывает.
*
* В демо-рамке телефона snap-точки заданы в пикселях под её высоту, чтобы «половина» и «полный»
* визуально различались. На реальном устройстве для тех же состояний используйте доли вьюпорта —
* `snapPoints={[0.5, 1]}` (резолвятся относительно высоты вьюпорта, а не контейнера).
*/
export function Expandable() {
const [open, setOpen] = useState(false);
return (
<MobilePreview>
<Button label='Открыть Expandable' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
snapPoints={['220px', '430px']}
defaultSnapIndex={0}
title='Expandable bottom-sheet'
content={
<div>
{Array.from({ length: 20 }).map((_, i) => (
<p key={i}>Параграф {i + 1}. Контент для демонстрации snap-points поведения.</p>
))}
</div>
}
approveButton={{ label: 'Закрыть', onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}Фильтры
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { SegmentControl } from '@cloud-ru/ds-segment-control';
import { Tag } from '@cloud-ru/ds-tag';
import { Switch } from '@cloud-ru/ds-toggles';
import { QuestionTooltip } from '@cloud-ru/ds-tooltip';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
import styles from './styles.module.scss';
const PERIOD_ITEMS = [
{ value: 'day', label: 'День' },
{ value: 'week', label: 'Неделя' },
{ value: 'month', label: 'Месяц' },
];
/**
* Реальный сценарий «Фильтры»: back-кнопка + заголовок с подсказкой, sticky-зона chips
* над контентом (subtitle), форма с SegmentControl и переключателями в теле и пара
* действий «Применить / Сбросить» в футере.
*/
export function Filters() {
const [open, setOpen] = useState(false);
const [period, setPeriod] = useState('week');
const [onlyFavourite, setOnlyFavourite] = useState(true);
const [withArchived, setWithArchived] = useState(false);
const [chips, setChips] = useState(['Активные', 'За месяц']);
return (
<MobilePreview>
<Button label='Открыть фильтры' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Фильтры'
onBackButtonClick={() => setOpen(false)}
slotAfterTitle={<QuestionTooltip tip='Настройте параметры выборки' />}
slotSecondTitle={
<div className={styles.chipRow}>
{chips.map(chip => (
<Tag
key={chip}
label={chip}
appearance='primary'
size='s'
onDelete={() => setChips(prev => prev.filter(c => c !== chip))}
/>
))}
</div>
}
content={
<div className={styles.column}>
<SegmentControl items={PERIOD_ITEMS} value={period} onChange={setPeriod} width='full' />
<div className={styles.switchRow}>
<span>Только избранное</span>
<Switch checked={onlyFavourite} onChange={setOnlyFavourite} />
</div>
<div className={styles.switchRow}>
<span>Показывать архив</span>
<Switch checked={withArchived} onChange={setWithArchived} />
</div>
</div>
}
approveButton={{ label: 'Применить', onClick: () => setOpen(false) }}
cancelButton={{
label: 'Сбросить',
onClick: () => {
setPeriod('week');
setOnlyFavourite(false);
setWithArchived(false);
setChips([]);
},
}}
/>
</MobilePreview>
);
}Выбор из списка
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { Checkbox } from '@cloud-ru/ds-toggles';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
import styles from './styles.module.scss';
const OPTIONS = [
{ id: 'compute', label: 'Compute' },
{ id: 'storage', label: 'Object Storage' },
{ id: 'network', label: 'Networking' },
{ id: 'database', label: 'Managed Databases' },
];
/**
* Сценарий выбора из списка: заголовок, чекбокс «Выбрать все» c indeterminate-состоянием
* для частичного выбора, список строк-опций и действие «Готово» в футере.
*/
export function SelectionList() {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>(['compute']);
const allChecked = selected.length === OPTIONS.length;
const someChecked = selected.length > 0 && !allChecked;
const toggle = (id: string) => setSelected(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]));
const toggleAll = () => setSelected(allChecked ? [] : OPTIONS.map(o => o.id));
return (
<MobilePreview>
<Button label='Выбрать сервисы' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Сервисы'
withDividers
content={
<div className={styles.column}>
{/* htmlFor связывает подпись с нативным input'ом внутри Checkbox — клик по тексту переключает чекбокс. */}
<label className={styles.checkRow} htmlFor='sel-all'>
<Checkbox id='sel-all' checked={allChecked} indeterminate={someChecked} onChange={toggleAll} />
<span>Выбрать все</span>
</label>
{OPTIONS.map(option => (
<label key={option.id} className={styles.checkRow} htmlFor={`sel-${option.id}`}>
<Checkbox
id={`sel-${option.id}`}
checked={selected.includes(option.id)}
onChange={() => toggle(option.id)}
/>
<span>{option.label}</span>
</label>
))}
</div>
}
approveButton={{ label: `Готово (${selected.length})`, onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}Picker тегов
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { Search } from '@cloud-ru/ds-search';
import { Tag } from '@cloud-ru/ds-tag';
import { QuestionTooltip } from '@cloud-ru/ds-tooltip';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
import styles from './styles.module.scss';
const ALL_TAGS = ['Production', 'Staging', 'Dev', 'Backend', 'Frontend', 'Database', 'Network', 'Critical', 'Billing'];
/**
* Picker тегов: заголовок с подсказкой, поиск в sticky-зоне (subtitle) фильтрует список,
* сетка тегов в теле переключает выбор по клику, футер подтверждает выбор.
*/
export function TagPicker() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<string[]>(['Production']);
const visible = ALL_TAGS.filter(tag => tag.toLowerCase().includes(query.toLowerCase()));
const toggle = (tag: string) =>
setSelected(prev => (prev.includes(tag) ? prev.filter(x => x !== tag) : [...prev, tag]));
return (
<MobilePreview>
<Button label='Выбрать теги' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
<BottomSheet
open={open}
onClose={() => setOpen(false)}
title='Теги'
slotAfterTitle={<QuestionTooltip tip='Отметьте теги, по которым нужно отфильтровать' />}
slotSecondTitle={<Search value={query} onChange={setQuery} placeholder='Поиск тега' />}
content={
<div className={styles.tagGrid}>
{visible.map(tag => (
<Tag
key={tag}
label={tag}
size='s'
appearance={selected.includes(tag) ? 'primary' : 'neutral'}
onClick={() => toggle(tag)}
/>
))}
</div>
}
approveButton={{ label: `Применить (${selected.length})`, onClick: () => setOpen(false) }}
cancelButton={{ label: 'Отмена', onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}Non-modal
Регион: ru-moscow-1 — список виртуальных машин. Прокрутите его, пока подсказка открыта.
vm-01 — ru-moscow-1a
vm-02 — ru-moscow-1a
vm-03 — ru-moscow-1a
vm-04 — ru-moscow-1a
vm-05 — ru-moscow-1a
vm-06 — ru-moscow-1a
vm-07 — ru-moscow-1a
vm-08 — ru-moscow-1a
vm-09 — ru-moscow-1a
vm-10 — ru-moscow-1a
vm-11 — ru-moscow-1a
vm-12 — ru-moscow-1a
vm-13 — ru-moscow-1a
vm-14 — ru-moscow-1a
import { BottomSheet } from '@cloud-ru/ds-bottom-sheet';
import { Button } from '@cloud-ru/ds-button';
import { useState } from 'react';
import { MobilePreview } from '../MobilePreview';
import styles from './styles.module.scss';
const RESOURCES = Array.from({ length: 14 }, (_, i) => `vm-${String(i + 1).padStart(2, '0')} — ru-moscow-1a`);
/**
* Non-modal sheet: фон не затемняется (`showBackdrop={false}`) и не блокируется
* (`lockScroll={false}`) — страница под подсказкой остаётся видимой, скроллится и кликается.
* Свайп отключён (`swipeEnabled={false}`), потому что подсказку закрывают кнопкой, а не жестом.
*/
export function NonModal() {
const [open, setOpen] = useState(true);
return (
<MobilePreview>
{/* Контент «страницы» под подсказкой. Список длиннее экрана — фон под non-modal sheet'ом
можно прокручивать, пока окно открыто (он не затемнён и не заблокирован). */}
<div className={styles.nonModalPage}>
<p>Регион: ru-moscow-1 — список виртуальных машин. Прокрутите его, пока подсказка открыта.</p>
<Button label='Показать подсказку' view='outline' appearance='neutral' onClick={() => setOpen(true)} />
{RESOURCES.map(name => (
<p key={name}>{name}</p>
))}
</div>
<BottomSheet
open={open}
onClose={() => setOpen(false)}
showBackdrop={false}
lockScroll={false}
swipeEnabled={false}
title='Совет'
content={<p>Откройте «Расширенные настройки», чтобы выбрать зону доступности вручную.</p>}
approveButton={{ label: 'Понятно', onClick: () => setOpen(false) }}
/>
</MobilePreview>
);
}Для ручной сборки разметки из Header / Body / Footer — см. BottomSheetCustom.
Props
Types
BottomSheetProps| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
actionButton | ReactNode | — | no | Action-кнопка справа в шапке (любой ReactNode — обычно `Button view='function'`). |
additionalButton | BottomSheetActionButton | — | no | Дополнительная (третья) кнопка — объект пропсов `Button` (по умолчанию `view='simple'`, `appearance='neutral'`). |
approveButton | BottomSheetActionButton | — | no | Основная кнопка действия — объект пропсов `Button` (по умолчанию `view='filled'`, `appearance='primary'`). Ширина зависит от `footerActionsOrientation` и числа кнопок. |
bodyPadding | boolean | true | no | Горизонтальные паддинги body. При `false` контент идёт во всю ширину (edge-to-edge) — для карт, изображений, списков без отступов. Соответствует Figma-оси `padding=false`. |
cancelButton | BottomSheetActionButton | — | no | Кнопка отмены — объект пропсов `Button` (по умолчанию `view='outline'`, `appearance='neutral'`). |
className | string | — | no | CSS-класс самого sheet-контейнера. |
closeOnPopstate | boolean | true | no | Закрывать sheet при `popstate` (browser-back на mobile). |
container | string | HTMLElement | — | no | Контейнер для портала. По дефолту — `body` либо контекст-провайдер `@cloud-ru/ds-portal-context`. |
content | ReactNode | — | no | Основное содержимое (рендерится в `BottomSheetCustom.Body`). |
data-test-id | string | — | no | |
defaultSnapIndex | number | 0 | no | Индекс snap'а, на котором sheet открывается по дефолту. Игнорируется при controlled `snapIndex`. |
footer | ReactNode | — | no | Произвольный футер. Если задан — имеет приоритет над `approveButton` / `cancelButton` / `additionalButton`. |
footerActionsOrientation | "horizontal" | "vertical" | 'horizontal' | no | Ориентация кнопок футера, собранных из `approveButton` / `cancelButton` / `additionalButton`. Применяется **только при ровно двух** кнопках (canonical cancel/confirm): - `'horizontal'` — кнопки в ряд через space-between: secondary слева, primary с права, ширина по контенту. Точное соответствие Figma `bottomBar.buttonGroup`. - `'vertical'` — кнопки в столбик, full-width (primary сверху). Одна кнопка всегда рендерится full-width (одиночный CTA); три кнопки не помещаются в ряд на mobile-вьюпорте и всегда идут в столбик — для них значение игнорируется. Игнорируется при заданном `footer` (произвольная разметка футера). |
footerTestIds | { approve?: string; cancel?: string; additional?: string | undefined; } | undefined | — | no | Переопределение `data-test-id` собранных слотов футера (approve/cancel/additional). По умолчанию — собственные id `BottomSheet`. Адаптивные `Modal`/`Drawer` передают сюда свои `TEST_IDS.footer*`, чтобы футер метился одинаково на desktop-поверхности и в mobile-sheet'е. |
lockScroll | boolean | true | no | Блокировать ли скролл фона на время открытия (`react-remove-scroll`). При `false` страница под sheet'ом остаётся прокручиваемой — для non-modal сценариев (sheet поверх контента, с которым продолжают взаимодействовать). Обычно используется вместе с `showBackdrop={false}`. |
media | ReactNode | PopupMediaProps | — | no | Media-блок над шапкой: изображение / иконка либо произвольный `ReactNode`. |
onBackButtonClick | (() => void) | — | no | Callback клика на back-кнопку (слева в шапке). Наличие callback'а рендерит ArrowLeft-кнопку. |
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-узла, по которому ловится клик). |
slotAfterTitle | ReactNode | — | no | Slot справа от title (внутри той же строки) — типично `QuestionTooltip`, status badge. |
slotSecondTitle | ReactNode | — | no | Slot под подзаголовком — типично `SearchBar`, `SegmentControl`, `Filter`. |
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`); внутри массива фиксированных позиций его «контентная» высота не определена. |
subtitle | ReactNode | — | no | Текстовая строка-подзаголовок под title. |
swipeEnabled | boolean | true | no | Включает swipe-down для закрытия / swipe-up для раскрытия на следующий snap-point. При `swipeEnabled=false` snap-point по-прежнему можно переключить через controlled `snapIndex` prop'ом. |
title | ReactNode | — | no | Заголовок в шапке. |
withDividers | boolean | true | no | Тонкие линии между topBar↔body и body↔footer: разграничивают закреплённые шапку и подвал от прокручиваемого под ними содержимого. Передайте `false`, чтобы убрать обе линии. |
Types
BottomSheetProps
SnapPoint
Related props
BottomSheetActionButton
FooterActionsOrientation
PopupMediaProps
Storybook
Figma
Смотри также
- BottomSheetCustom — низкоуровневая ручная композиция.
- Drawer — выезжающая боковая панель (desktop / мульти-position).
- Modal — модальное окно по центру.
- Toaster — короткие mobile-уведомления.
- Popover — компактный поповер у триггера.