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}.

  • 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 собирается из объектов пропсов 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 справа), ширина по контенту. Дефолт — точное соответствие Figma bottomBar.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 (default true)false отключает swipe-жесты; переключить snap можно только программно через snapIndex.
  • closeOnPopstate (default true) — закрытие по 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’ом используйте controlled snapIndex со своим контролом.
  • 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'

Примеры использования

Базовый сценарий

Базовый сценарийtitle + content + одиночная full-width кнопка
9:41●●● ▮
tsx
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>
  );
}

Кнопки футера + дисклеймер

Кнопки футера + дисклеймерapprove + cancel + additional + disclaimer — три действия собираются в вертикальный full-width ButtonGroup
9:41●●● ▮
tsx
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-блоком

С media-блокомkind=image — full-bleed картинка над контентом
9:41●●● ▮
tsx
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

С subtitleSearchBar / SegmentControl под строкой заголовка
9:41●●● ▮
tsx
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-кнопкой

С back- и action-кнопкойonBackButtonClick авто-рендерит back-кнопку; actionButton — справа в шапке
9:41●●● ▮
tsx
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

Scrollable + dividersДлинный контент: тонкие линии разделяют header / footer от плывущего тела
9:41●●● ▮
tsx
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

Expandable: половина → fullsnapPoints={[0.5, 1]} — drag вверх раскрывает
9:41●●● ▮
tsx
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>
  );
}

Фильтры

ФильтрыBack-кнопка + подсказка, chips в subtitle, SegmentControl и переключатели, «Применить / Сбросить»
9:41●●● ▮
tsx
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>
  );
}

Выбор из списка

Выбор из спискаЧекбоксы с «Выбрать все» (indeterminate) и счётчиком выбранного в действии
9:41●●● ▮
tsx
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 тегов

Picker теговПоиск в subtitle фильтрует сетку тегов; клик переключает выбор
9:41●●● ▮
tsx
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

Non-modalshowBackdrop={false} + lockScroll={false} — фон не затемнён и остаётся прокручиваемым
9:41●●● ▮

Регион: 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

tsx
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

PropsBottomSheetProps
PropTypeDefaultRequiredDescription
actionButtonReactNodenoAction-кнопка справа в шапке (любой ReactNode — обычно `Button view='function'`).
additionalButtonBottomSheetActionButtonnoДополнительная (третья) кнопка — объект пропсов `Button` (по умолчанию `view='simple'`, `appearance='neutral'`).
approveButtonBottomSheetActionButtonnoОсновная кнопка действия — объект пропсов `Button` (по умолчанию `view='filled'`, `appearance='primary'`). Ширина зависит от `footerActionsOrientation` и числа кнопок.
bodyPaddingbooleantruenoГоризонтальные паддинги body. При `false` контент идёт во всю ширину (edge-to-edge) — для карт, изображений, списков без отступов. Соответствует Figma-оси `padding=false`.
cancelButtonBottomSheetActionButtonnoКнопка отмены — объект пропсов `Button` (по умолчанию `view='outline'`, `appearance='neutral'`).
classNamestringnoCSS-класс самого sheet-контейнера.
closeOnPopstatebooleantruenoЗакрывать sheet при `popstate` (browser-back на mobile).
containerstring | HTMLElementnoКонтейнер для портала. По дефолту — `body` либо контекст-провайдер `@cloud-ru/ds-portal-context`.
contentReactNodenoОсновное содержимое (рендерится в `BottomSheetCustom.Body`).
data-test-idstringno
defaultSnapIndexnumber0noИндекс snap'а, на котором sheet открывается по дефолту. Игнорируется при controlled `snapIndex`.
footerReactNodenoПроизвольный футер. Если задан — имеет приоритет над `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; } | undefinednoПереопределение `data-test-id` собранных слотов футера (approve/cancel/additional). По умолчанию — собственные id `BottomSheet`. Адаптивные `Modal`/`Drawer` передают сюда свои `TEST_IDS.footer*`, чтобы футер метился одинаково на desktop-поверхности и в mobile-sheet'е.
lockScrollbooleantruenoБлокировать ли скролл фона на время открытия (`react-remove-scroll`). При `false` страница под sheet'ом остаётся прокручиваемой — для non-modal сценариев (sheet поверх контента, с которым продолжают взаимодействовать). Обычно используется вместе с `showBackdrop={false}`.
mediaReactNode | PopupMediaPropsnoMedia-блок над шапкой: изображение / иконка либо произвольный `ReactNode`.
onBackButtonClick(() => void)noCallback клика на back-кнопку (слева в шапке). Наличие callback'а рендерит ArrowLeft-кнопку.
onClose() => voidyesКолбэк закрытия (вызывается при click outside, Esc, swipe-down, browser-back).
onSnapIndexChange((snapIndex: number) => void)noCallback изменения активного snap'а (пересечение swipe-границы или click по UI). Не вызывается при программной смене controlled `snapIndex`.
openbooleanyesУправление состоянием показан / не показан.
rootClassNamestringnoCSS-класс корневого элемента portal'а.
safeAreabooleantruenoРезервировать ли место под iOS notch / home-indicator и Android nav-bar. Реализовано паддингом на `.content` через `env(safe-area-inset-*)`: на устройстве без выреза/индикатора (и на desktop) inset = 0, поэтому никакого «лишнего» отступа не появляется; на notched-устройстве — ровно нужный. Верхний отступ добавляется только когда sheet раскрыт на полный вьюпорт (его верх под notch).
showBackdropbooleantruenoОтображение тёмной подложки за sheet'ом. При `false` фон не затемняется и click-outside не закрывает sheet (нет backdrop-узла, по которому ловится клик).
slotAfterTitleReactNodenoSlot справа от title (внутри той же строки) — типично `QuestionTooltip`, status badge.
slotSecondTitleReactNodenoSlot под подзаголовком — типично `SearchBar`, `SegmentControl`, `Filter`.
snapIndexnumbernoControlled-индекс активного snap'а. Если задан, sheet всегда находится на этом snap'е; swipe-up/down вызывают `onSnapIndexChange`, но не меняют позицию сами — consumer должен передать новое значение.
snapPointsSnapPoint[]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`); внутри массива фиксированных позиций его «контентная» высота не определена.
subtitleReactNodenoТекстовая строка-подзаголовок под title.
swipeEnabledbooleantruenoВключает swipe-down для закрытия / swipe-up для раскрытия на следующий snap-point. При `swipeEnabled=false` snap-point по-прежнему можно переключить через controlled `snapIndex` prop'ом.
titleReactNodenoЗаголовок в шапке.
withDividersbooleantruenoТонкие линии между topBar↔body и body↔footer: разграничивают закреплённые шапку и подвал от прокручиваемого под ними содержимого. Передайте `false`, чтобы убрать обе линии.

Types

BottomSheetProps

Storybook

Figma

Смотри также

  • BottomSheetCustom — низкоуровневая ручная композиция.
  • Drawer — выезжающая боковая панель (desktop / мульти-position).
  • Modal — модальное окно по центру.
  • Toaster — короткие mobile-уведомления.
  • Popover — компактный поповер у триггера.