BottomSheetCustom

BottomSheetCustom — низкоуровневая версия BottomSheet, которая не диктует структуру содержимого. Потребитель сам компонует шапку, тело и футер из субкомпонентов BottomSheetCustom.Header, .Body, .Footer или собственной разметки.

Сам компонент берёт на себя portal, backdrop, slide-up-motion, focus-trap и swipe / snap-движок. Готовая анатомия (media-блок, dividers, авто-рендер back-кнопки) есть только у высокоуровневого BottomSheet.

Когда использовать

  • Стандартной шапки / футера из BottomSheet недостаточно — нужна своя разметка.
  • Сложная раскладка нескольких секций внутри одного sheet’а.
  • Кастомные слоты (например, фиксированный поиск между шапкой и телом).

Во всех остальных случаях предпочтительнее BottomSheet — он дешевле в поддержке и даёт консистентные отступы.

Анатомия

Слот BottomSheetCustom.Headertitle, 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’а.

Слот 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'

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

Ручная композиция

Ручная композицияHeader + Body + Footer собираются вручную.
9:41●●● ▮
tsx
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

Snap points: половина → fullsnapPoints={[0.5, 1]} + controlled snapIndex
9:41●●● ▮
tsx
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

Scrollable bodyДлинный список скроллится внутри Body, header / footer фиксированы
9:41●●● ▮
tsx
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>
  );
}

Без анимации

Без анимацииdisableMotions — мгновенное открытие / закрытие без slide-up
9:41●●● ▮
tsx
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

PropsBottomSheetCustomProps
PropTypeDefaultRequiredDescription
childrenstring | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefinedno
classNamestringnoCSS-класс самого sheet-контейнера.
closeOnPopstatebooleantruenoЗакрывать sheet при `popstate` (browser-back на mobile).
containerstring | HTMLElementnoКонтейнер для портала. По дефолту — `body` либо контекст-провайдер `@cloud-ru/ds-portal-context`.
data-test-idstringno
defaultSnapIndexnumber0noИндекс snap'а, на котором sheet открывается по дефолту. Игнорируется при controlled `snapIndex`.
disableMotionsbooleanfalsenoОтключить анимации открытия / закрытия и перехода между snap-точками.
lockScrollbooleantruenoБлокировать ли скролл фона на время открытия (`react-remove-scroll`). При `false` страница под sheet'ом остаётся прокручиваемой — для non-modal сценариев (sheet поверх контента, с которым продолжают взаимодействовать). Обычно используется вместе с `showBackdrop={false}`.
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-узла, по которому ловится клик).
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`); внутри массива фиксированных позиций его «контентная» высота не определена.
swipeEnabledbooleantruenoВключает swipe-down для закрытия / swipe-up для раскрытия на следующий snap-point. При `swipeEnabled=false` snap-point по-прежнему можно переключить через controlled `snapIndex` prop'ом.

Types

BottomSheetCustomProps

Storybook

Figma

Смотри также

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