Bottom sheet

사용자가 현재 맥락과 밀접한 관련이 있는 범위가 정해진 작업을 수행할 수 있도록 돕는 요소입니다. 모바일 화면 하단에 오버레이되어 추가적인 사항이나 액션을 표시합니다. 메인 콘텐츠에 대한 접근성을 유지하면서 임시적으로 간단한 정보를 쉽게 접근할 수 있게 해줍니다.

Basic Bottom sheet

Bottom sheet를 표시합니다.

breakpoint에 따라 variant를 override 하여 Popup 과 쉽게 통합할 수 있습니다.

import {
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalSummary,
  ModalTrigger,
} from '@montage-ui/core';

const Demo = () => {
  return (
    <Modal>
      <ModalTrigger>
        <Button>Open</Button>
      </ModalTrigger>

      <ModalContainer variant="bottom" handle>
        <ModalContent>
          <ModalContentItem>
            <ModalHeading>Heading</ModalHeading>
            <ModalSummary>Summary</ModalSummary>
            <ModalDescription>Description</ModalDescription>
          </ModalContentItem>
        </ModalContent>

        <ActionArea>
          <ActionAreaButton>
            Main action
          </ActionAreaButton>
        </ActionArea>
      </ModalContainer>
    </Modal>
  )
}

export default Demo;

Anatomy

Bottom sheet 는 여러 컴포넌트를 조합해서 사용합니다.

ModalTrigger, ModalNavigation 은 선택적으로 사용할 수 있습니다.

기본 구성은 아래와 같습니다.

<Modal>
  <ModalTrigger>
    <Button />
  </ModalTrigger>

  <ModalContainer>
    <ModalNavigation />

    <ModalContent>
      <ModalContentItem>
        <ModalHeading />
        <ModalSummary />
        <ModalDescription />
      </ModalContentItem>
    </ModalContent>

    <ActionArea>
      <ActionAreaButton />
    </ActionArea>
  </ModalContainer>
</Modal>

Sizes

1가지 사이즈만 사용합니다.

  • medium
import {
  FlexBox,
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalSummary,
  ModalTrigger,
} from '@montage-ui/core';

const Demo = () => {
  return (
    <FlexBox gap="12px" flexWrap="wrap">
      <Modal>
        <ModalTrigger>
          <Button>Medium</Button>
        </ModalTrigger>

        <ModalContainer variant="bottom" size="medium" handle>
          <ModalContent>
            <ModalContentItem>
              <ModalHeading>Heading</ModalHeading>
              <ModalSummary>Summary</ModalSummary>
              <ModalDescription>Description</ModalDescription>
            </ModalContentItem>
          </ModalContent>

          <ActionArea>
            <ActionAreaButton>
              Main action
            </ActionAreaButton>
          </ActionArea>
        </ModalContainer>
      </Modal>
    </FlexBox>
  )
}

export default Demo;

Resize

resize 옵션을 사용하면 Bottom sheet가 차지하는 높이를 조정할 수 있습니다.

  • hug (default) — 컨텐츠 크기에 맞춥니다.
  • fixed — size 별 고정 높이를 사용합니다.
  • fill — 화면이 허용하는 최대 높이까지 시트를 채웁니다.
  • flexible — 최대 높이의 절반(half)만 노출되며, 위로 끌어올리면 최대 높이(full)까지 확장됩니다. Flexible usage
import {
  FlexBox,
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalNavigation,
  ModalHeading,
  ModalSummary,
  ModalTrigger,
} from '@montage-ui/core';

const Demo = () => {
  return (
    <FlexBox gap="12px" flexWrap="wrap">
      <Modal>
        <ModalTrigger>
          <Button>Hug</Button>
        </ModalTrigger>

        <ModalContainer variant="bottom" resize="hug">
          <ModalNavigation>
            Hug
          </ModalNavigation>

          <ModalContent>
            <ModalContentItem>
              <ModalHeading>Heading</ModalHeading>
              <ModalSummary>Summary</ModalSummary>
              <ModalDescription>Description</ModalDescription>
            </ModalContentItem>
          </ModalContent>

          <ActionArea>
            <ActionAreaButton>
              Main action
            </ActionAreaButton>
          </ActionArea>
        </ModalContainer>
      </Modal>

      <Modal>
        <ModalTrigger>
          <Button>Fixed</Button>
        </ModalTrigger>

        <ModalContainer variant="bottom" resize="fixed">
          <ModalNavigation>
            Fixed
          </ModalNavigation>

          <ModalContent>
            <ModalContentItem>
              <ModalHeading>Heading</ModalHeading>
              <ModalSummary>Summary</ModalSummary>
              <ModalDescription>Description</ModalDescription>
            </ModalContentItem>
          </ModalContent>

          <ActionArea>
            <ActionAreaButton>
              Main action
            </ActionAreaButton>
          </ActionArea>
        </ModalContainer>
      </Modal>

      <Modal>
        <ModalTrigger>
          <Button>Fill</Button>
        </ModalTrigger>

        <ModalContainer variant="bottom" resize="fill">
          <ModalNavigation>
            Fill
          </ModalNavigation>

          <ModalContent>
            <ModalContentItem>
              <ModalHeading>Heading</ModalHeading>
              <ModalSummary>Summary</ModalSummary>
              <ModalDescription>Description</ModalDescription>
            </ModalContentItem>
          </ModalContent>

          <ActionArea>
            <ActionAreaButton>
              Main action
            </ActionAreaButton>
          </ActionArea>
        </ModalContainer>
      </Modal>
    </FlexBox>
  )
}

export default Demo;

Handle

Bottom sheet의 상단에 핸들을 표시하여 드래그로 숨기고 열 수 있는 옵션을 사용할 수 있습니다.

  • true
  • false (default)

Peek height

peekHeight 옵션을 0보다 큰 값으로 설정하면 handle로 조작 혹은 dimmer 영역 클릭 시 닫히지 않고 아래로 숨겨집니다.

이 때 숨겨지는 영역의 높이는 peekHeight 값을 사용합니다.

import {
  FlexBox,
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalSummary,
  ModalTrigger,
} from '@montage-ui/core';
import { useState } from 'react';

const Demo = () => {
  const [open, setOpen] = useState(false);

  return (
    <Modal open={open} onOpenChange={setOpen}>
      <ModalTrigger>
        <Button>Peek height</Button>
      </ModalTrigger>

      <ModalContainer variant="bottom" handle peekHeight={76}>
        <ModalContent>
          <ModalContentItem>
            <ModalHeading>Heading</ModalHeading>
            <ModalSummary>Summary</ModalSummary>
            <ModalDescription>Description</ModalDescription>
          </ModalContentItem>
        </ModalContent>

        <ActionArea>
          <ActionAreaButton onClick={() => setOpen(false)} variant="alternative">
            Close sheet
          </ActionAreaButton>
        </ActionArea>
      </ModalContainer>
    </Modal>
  )
}

export default Demo;

Basic flexible sheet

resize="flexible" 옵션은 처음에는 최대 높이의 50%(half)만 노출되었다가, 핸들을 위로 끌어올리거나 시트 내부에서 위로 스와이프하면 최대 높이(full)까지 확장되는 동작을 제공합니다.

  • 해당 옵션을 사용하면 자동으로 handle={true}로 간주합니다.
  • 초기 스냅을 full로 시작하려면 defaultSnap="full" 을 사용합니다.
  • 스냅이 변경될 때마다 onSnapChange(snap) 콜백이 호출됩니다. ('peek' (peekHeight가 있을 때만) | 'half' | 'full')
import {
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalSummary,
  ModalTrigger,
} from '@montage-ui/core';

const Demo = () => {
  return (
    <Modal>
      <ModalTrigger>
        <Button>Flexible</Button>
      </ModalTrigger>

      <ModalContainer variant="bottom" resize="flexible" handle>
        <ModalContent>
          <ModalContentItem>
            <ModalHeading>Heading</ModalHeading>
            <ModalSummary>핸들을 위로 끌어올리거나 컨텐츠를 위로 스와이프하면 full로 확장됩니다.</ModalSummary>
            <ModalDescription>
              {Array.from({ length: 30 }).map((_, i) => (
                <span key={i} style={{ display: 'block' }}>Scrollable line #{i + 1}</span>
              ))}
            </ModalDescription>
          </ModalContentItem>
           <ModalContentItem>
            <ModalHeading>Heading</ModalHeading>
            <ModalSummary>핸들을 위로 끌어올리거나 컨텐츠를 위로 스와이프하면 full로 확장됩니다.</ModalSummary>
            <ModalDescription>
              {Array.from({ length: 30 }).map((_, i) => (
                <span key={i} style={{ display: 'block' }}>Scrollable line #{i + 1}</span>
              ))}
            </ModalDescription>
          </ModalContentItem>
           <ModalContentItem>
            <ModalHeading>Heading</ModalHeading>
            <ModalSummary>핸들을 위로 끌어올리거나 컨텐츠를 위로 스와이프하면 full로 확장됩니다.</ModalSummary>
            <ModalDescription>
              {Array.from({ length: 30 }).map((_, i) => (
                <span key={i} style={{ display: 'block' }}>Scrollable line #{i + 1}</span>
              ))}
            </ModalDescription>
          </ModalContentItem>
        </ModalContent>

        <ActionArea>
          <ActionAreaButton>
            Main action
          </ActionAreaButton>
        </ActionArea>
      </ModalContainer>
    </Modal>
  )
}

export default Demo;

Snap change

resize="flexible" 인 시트에서 스냅('peek' | 'half' | 'full')이 변경될 때마다 onSnapChange 콜백이 호출됩니다.

현재 스냅에 따라 외부 상태를 동기화하거나, 특정 스냅에서 부가 UI를 표시할 때 사용합니다.

defaultSnap 으로 초기 스냅을 지정할 수 있으며 ('half' 가 기본값), peekHeight 와 함께 사용하면 peek ↔ half ↔ full 3단 스냅이 활성화됩니다.

import { useState } from 'react';
import {
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalTrigger,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalSummary,
} from '@montage-ui/core';

const Demo = () => {
  const [open, setOpen] = useState(false);
  const [snap, setSnap] = useState('half');

  return (
    <Modal open={open} onOpenChange={setOpen}>
      <ModalTrigger>
        <Button>Snap change</Button>
      </ModalTrigger>

      <ModalContainer
        variant="bottom"
        resize="flexible"
        defaultSnap="half"
        peekHeight={72}
        handle
        onSnapChange={setSnap}
      >
        <ModalContent>
          <ModalContentItem>
            <ModalHeading>현재 스냅: {snap}</ModalHeading>
            <ModalSummary>핸들을 끌어올리거나 컨텐츠를 위로 스와이프해보세요.</ModalSummary>
            <ModalDescription>
              {Array.from({ length: 30 }).map((_, i) => (
                <span key={i} style={{ display: 'block' }}>Scrollable line #{i + 1}</span>
              ))}
            </ModalDescription>
          </ModalContentItem>
        </ModalContent>

        <ActionArea>
          <ActionAreaButton onClick={() => setOpen(false)} variant="alternative">
            Close sheet
          </ActionAreaButton>
        </ActionArea>
      </ModalContainer>
    </Modal>
  )
}

export default Demo;

Largest undimmed snap

largestUndimmedSnap 은 dimmer가 꺼지는 스냅을 지정합니다. 지정한 스냅 이하의 모든 스냅에서는 dimmer가 표시되지 않고, 시트 뒤 콘텐츠로 포인터 이벤트가 통과합니다.

iOS의 largestUndimmedDetentIdentifier 와 같은 역할을 합니다.

  • 'peek' (기본값): peek 만 undimmed. half / full 에서는 dimmer가 표시됩니다.
  • 'half': peek 과 half 모두 undimmed. full 에서만 dimmer가 표시됩니다. (resize="flexible" 에서만 유효)

지도 위에 시트를 띄우고 half 상태에서도 지도를 조작 가능하게 하는 등의 UX 에 사용합니다.

import { useState } from 'react';
import {
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalTrigger,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalSummary,
} from '@montage-ui/core';

const Demo = () => {
  const [open, setOpen] = useState(false);

  return (
    <Modal open={open} onOpenChange={setOpen}>
      <ModalTrigger>
        <Button>Largest undimmed = half</Button>
      </ModalTrigger>

      <ModalContainer
        variant="bottom"
        resize="flexible"
        defaultSnap="half"
        peekHeight={96}
        largestUndimmedSnap="half"
        handle
      >
        <ModalContent>
          <ModalContentItem>
            <ModalHeading>Half 까지 undimmed</ModalHeading>
            <ModalSummary>full 로 끌어올렸을 때만 dimmer 가 표시됩니다.</ModalSummary>
            <ModalDescription>
              {Array.from({ length: 30 }).map((_, i) => (
                <span key={i} style={{ display: 'block' }}>Scrollable line #{i + 1}</span>
              ))}
            </ModalDescription>
          </ModalContentItem>
        </ModalContent>

        <ActionArea>
          <ActionAreaButton onClick={() => setOpen(false)} variant="alternative">
            Close sheet
          </ActionAreaButton>
        </ActionArea>
      </ModalContainer>
    </Modal>
  )
}

export default Demo;

Half snap scroll

enableHalfSnapScroll 은 half 스냅 + 스크롤 가능한 콘텐츠가 함께 있을 때의 제스처 라우팅 정책을 토글합니다.

  • false (default): half 에서의 viewport 드래그는 항상 시트를 움직입니다. 시트가 정책상 우선이므로 본문 스크롤은 발생하지 않습니다. 짧은 콘텐츠 또는 시트 자체가 주된 인터랙션인 경우 적합합니다.
  • true: half 상태에서 스크롤 가능한 콘텐츠가 있으면 본문 스크롤이 먼저 동작합니다.

full 스냅에서는 이 옵션과 무관하게 항상 본문 스크롤이 우선으로 동작합니다.

import { useState } from 'react';
import {
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalTrigger,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalSummary,
} from '@montage-ui/core';

const Demo = () => {
  const [open, setOpen] = useState(false);

  return (
    <Modal open={open} onOpenChange={setOpen}>
      <ModalTrigger>
        <Button>Half snap scroll</Button>
      </ModalTrigger>

      <ModalContainer
        variant="bottom"
        resize="flexible"
        defaultSnap="half"
        peekHeight={96}
        enableHalfSnapScroll
        handle
      >
        <ModalContent>
          <ModalContentItem>
            <ModalHeading>Scroll → 경계 → 시트 축소</ModalHeading>
            <ModalSummary>half 에서 스크롤하다 맨 위에 닿으면 ↓ 드래그가 시트를 닫는 제스처로 자동 전환됩니다.</ModalSummary>
            <ModalDescription>
              {Array.from({ length: 60 }).map((_, i) => (
                <span key={i} style={{ display: 'block' }}>Scrollable line #{i + 1}</span>
              ))}
            </ModalDescription>
          </ModalContentItem>
        </ModalContent>

        <ActionArea>
          <ActionAreaButton onClick={() => setOpen(false)} variant="alternative">
            Close sheet
          </ActionAreaButton>
        </ActionArea>
      </ModalContainer>
    </Modal>
  )
}

export default Demo;

Content

내부 콘텐츠는 ModalContent 로 감싸서 사용하는 것을 권장하며, verticalPadding, horizontalPadding, gap 옵션을 사용하여 내부 여백을 조정할 수 있습니다.

verticalPadding

  • none
  • top-only (default)
  • bottom-only
  • both

horizontalPadding

  • none
  • both (default)
import {
  FlexBox,
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalNavigation,
  ModalSummary,
  ModalTrigger,
  Typography,
  SegmentedControl,
  SegmentedControlItem,
  addOpacity,
} from '@montage-ui/core';
import { useState } from 'react';

const Control = ({ label, value, onValueChange, options }) => (
  <FlexBox flexDirection="column" gap="8px" sx={{ width: '100%', maxWidth: 400 }}>
    <Typography variant="label1" weight="medium" color="semantic.foreground.neutral.secondary">
      {label}
    </Typography>
    <SegmentedControl size="small" value={value} onValueChange={onValueChange}>
      {options.map((option) => (
        <SegmentedControlItem key={option} value={option}>
          {option}
        </SegmentedControlItem>
      ))}
    </SegmentedControl>
  </FlexBox>
);

const Demo = () => {
  const [verticalPadding, setVerticalPadding] = useState('top-only');
  const [horizontalPadding, setHorizontalPadding] = useState('both');

  return (
    <FlexBox gap="12px" flexWrap="wrap">
      <Modal>
        <ModalTrigger>
          <Button>Open</Button>
        </ModalTrigger>

        <ModalContainer variant="bottom" handle>
          <ModalContent verticalPadding={verticalPadding} horizontalPadding={horizontalPadding}>
            <ModalContentItem>
              <FlexBox
                flexDirection="column"
                gap="12px"
                alignItems="center"
                justifyContent="center"
                sx={theme => ({
                  width: '100%',
                  height: '400px',
                  background: addOpacity(theme.semantic.surface.accent.violetOpaque, 0.08),
                })}
              >
                <Control
                  label="verticalPadding"
                  value={verticalPadding}
                  onValueChange={setVerticalPadding}
                  options={['none', 'top-only', 'bottom-only', 'both']}
                />
                <Control
                  label="horizontalPadding"
                  value={horizontalPadding}
                  onValueChange={setHorizontalPadding}
                  options={['none', 'both']}
                />
              </FlexBox>
            </ModalContentItem>
          </ModalContent>

          <ActionArea>
            <ActionAreaButton>
              Action
            </ActionAreaButton>
          </ActionArea>
        </ModalContainer>
      </Modal>
    </FlexBox>
  )
}

export default Demo;

3가지 variant의 Navigation을 사용할 수 있습니다.

  • emphasized (default)
  • floating
  • search

Navigation을 표시할 때는 handle={false} 로 사용하는 것을 권장합니다.

또한 ModalNavigation 의 leadingContent, trailingContent로 콘텐츠를 추가할 수 있으며 ModalNavigationButton 으로 사용합니다.

variant
button variant
leadingContent
trailingContent
import {
  FlexBox,
  Box,
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalNavigation,
  ModalNavigationButton,
  ModalTrigger,
  SearchField,
  SegmentedControl,
  SegmentedControlItem,
  Typography,
} from '@montage-ui/core';
import { useState } from 'react';

const Control = ({ label, value, onValueChange, options }) => (
  <FlexBox flexDirection="column" gap="8px" sx={{ width: '100%', maxWidth: 400 }}>
    <Typography variant="label1" weight="medium" color="semantic.foreground.neutral.secondary">
      {label}
    </Typography>
    <SegmentedControl size="small" value={value} onValueChange={onValueChange}>
      {options.map((option) => (
        <SegmentedControlItem key={option} value={option}>
          {option}
        </SegmentedControlItem>
      ))}
    </SegmentedControl>
  </FlexBox>
);

const Demo = () => {
  const [navigationVariant, setNavigationVariant] = useState('emphasized');
  const [buttonVariant, setButtonVariant] = useState('icon-button');
  const [leading, setLeading] = useState('on');
  const [trailing, setTrailing] = useState('on');
  const [background, setBackground] = useState('off');

  // background only applies to icon buttons in a floating navigation
  const supportsBackground = navigationVariant === 'floating' && buttonVariant === 'icon-button';
  const iconButtonBackground = supportsBackground ? background === 'on' : undefined;

  const leadingContent = leading === 'on' ? (
    <ModalNavigationButton variant={buttonVariant === 'icon-button' ? 'back-button' : buttonVariant} background={iconButtonBackground}>
      {buttonVariant === 'text-button' ? '뒤로' : undefined}
    </ModalNavigationButton>
  ) : null;

  const trailingContent = trailing === 'on' ? (
    <ModalNavigationButton variant={buttonVariant === 'icon-button' ? 'close-button' : buttonVariant} background={iconButtonBackground}>
      {buttonVariant === 'text-button' ? '닫기' : undefined}
    </ModalNavigationButton>
  ) : null;

  const getNavigationContent = () => {
    switch (navigationVariant) {
      case 'search':
        return <SearchField placeholder="Enter search keyword." />;
      case 'floating':
        return null;
      case 'emphasized':
        return 'Emphasized';
    }
  };

  return (
    <FlexBox flexDirection="column" alignItems="center" gap="24px" sx={{ width: '100%' }}>
      <Control
        label="variant"
        value={navigationVariant}
        onValueChange={setNavigationVariant}
        options={['emphasized', 'floating', 'search']}
      />
      <Control
        label="button variant"
        value={buttonVariant}
        onValueChange={setButtonVariant}
        options={['icon-button', 'text-button']}
      />
      <Control label="leadingContent" value={leading} onValueChange={setLeading} options={['on', 'off']} />
      <Control label="trailingContent" value={trailing} onValueChange={setTrailing} options={['on', 'off']} />
      {supportsBackground && (
        <Control
          label="button background"
          value={background}
          onValueChange={setBackground}
          options={['on', 'off']}
        />
      )}

      <Modal>
        <ModalTrigger>
          <Button>Open</Button>
        </ModalTrigger>

        <ModalContainer variant="bottom">
          <ModalNavigation
            variant={navigationVariant}
            leadingContent={leadingContent}
            trailingContent={trailingContent}
          >
            {getNavigationContent()}
          </ModalNavigation>

          <ModalContent>
            <ModalContentItem>
              <Box
                sx={theme => ({
                  width: '100%',
                  height: '800px',
                  background: theme.semantic.surface.accent.violetOpaque,
                  opacity: 0.08
                })}
              />
            </ModalContentItem>
          </ModalContent>

          <ActionArea>
            <ActionAreaButton>
              Action
            </ActionAreaButton>
          </ActionArea>
        </ModalContainer>
      </Modal>
    </FlexBox>
  )
}

export default Demo;

Full modal

variant="full" 옵션을 사용하면 전체 화면 모달 형태로 표시할 수 있습니다.

Popup Navigation 과 동일하게 Navigation을 사용할 수 있습니다.

import {
  Button,
  Modal,
  ModalTrigger,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalDescription,
  ModalHeading,
  ModalNavigation,
  ModalSummary,
} from '@montage-ui/core';

const Demo = () => {
  return (
    <Modal>
      <ModalTrigger>
        <Button>Full modal</Button>
      </ModalTrigger>

      <ModalContainer variant="full">
        <ModalNavigation>
          Title
        </ModalNavigation>

        <ModalContent>
          <ModalContentItem>
            <ModalHeading>Heading</ModalHeading>
            <ModalSummary>Summary</ModalSummary>
            <ModalDescription>Description</ModalDescription>
          </ModalContentItem>
        </ModalContent>

        <ActionArea>
          <ActionAreaButton>
            Main action
          </ActionAreaButton>
        </ActionArea>
      </ModalContainer>
    </Modal>
  )
}

export default Demo;

full variant에서는 normal variant가 추가된 4가지 variant의 Navigation을 사용할 수 있습니다.

  • normal (default)
  • emphasized
  • floating
  • search
variant
button variant
leadingContent
trailingContent
import {
  FlexBox,
  Box,
  Button,
  Modal,
  ActionArea,
  ActionAreaButton,
  ModalContainer,
  ModalContent,
  ModalContentItem,
  ModalNavigation,
  ModalNavigationButton,
  ModalTrigger,
  SearchField,
  SegmentedControl,
  SegmentedControlItem,
  Typography,
} from '@montage-ui/core';
import { useState } from 'react';

const Control = ({ label, value, onValueChange, options }) => (
  <FlexBox flexDirection="column" gap="8px" sx={{ width: '100%', maxWidth: 400 }}>
    <Typography variant="label1" weight="medium" color="semantic.foreground.neutral.secondary">
      {label}
    </Typography>
    <SegmentedControl size="small" value={value} onValueChange={onValueChange}>
      {options.map((option) => (
        <SegmentedControlItem key={option} value={option}>
          {option}
        </SegmentedControlItem>
      ))}
    </SegmentedControl>
  </FlexBox>
);

const Demo = () => {
  const [navigationVariant, setNavigationVariant] = useState('normal');
  const [buttonVariant, setButtonVariant] = useState('icon-button');
  const [leading, setLeading] = useState('on');
  const [trailing, setTrailing] = useState('on');
  const [background, setBackground] = useState('off');

  // background only applies to icon buttons in a floating navigation
  const supportsBackground = navigationVariant === 'floating' && buttonVariant === 'icon-button';
  const iconButtonBackground = supportsBackground ? background === 'on' : undefined;

  const leadingContent = leading === 'on' ? (
    <ModalNavigationButton variant={buttonVariant === 'icon-button' ? 'back-button' : buttonVariant} background={iconButtonBackground}>
      {buttonVariant === 'text-button' ? '뒤로' : undefined}
    </ModalNavigationButton>
  ) : null;

  const trailingContent = trailing === 'on' ? (
    <ModalNavigationButton variant={buttonVariant === 'icon-button' ? 'close-button' : buttonVariant} background={iconButtonBackground}>
      {buttonVariant === 'text-button' ? '닫기' : undefined}
    </ModalNavigationButton>
  ) : null;

  const getNavigationContent = () => {
    switch (navigationVariant) {
      case 'search':
        return <SearchField placeholder="Enter search keyword." />;
      case 'floating':
        return null;
      case 'emphasized':
        return 'Emphasized';
      case 'normal':
        return 'Normal';
    }
  };

  return (
    <FlexBox flexDirection="column" alignItems="center" gap="24px" sx={{ width: '100%' }}>
      <Control
        label="variant"
        value={navigationVariant}
        onValueChange={setNavigationVariant}
        options={['normal', 'emphasized', 'floating', 'search']}
      />
      <Control
        label="button variant"
        value={buttonVariant}
        onValueChange={setButtonVariant}
        options={['icon-button', 'text-button']}
      />
      <Control label="leadingContent" value={leading} onValueChange={setLeading} options={['on', 'off']} />
      <Control label="trailingContent" value={trailing} onValueChange={setTrailing} options={['on', 'off']} />
      {supportsBackground && (
        <Control
          label="button background"
          value={background}
          onValueChange={setBackground}
          options={['on', 'off']}
        />
      )}

      <Modal>
        <ModalTrigger>
          <Button>Open</Button>
        </ModalTrigger>

        <ModalContainer variant="full">
          <ModalNavigation
            variant={navigationVariant}
            leadingContent={leadingContent}
            trailingContent={trailingContent}
          >
            {getNavigationContent()}
          </ModalNavigation>

          <ModalContent>
            <ModalContentItem>
              <Box
                sx={theme => ({
                  width: '100%',
                  height: '800px',
                  background: theme.semantic.surface.accent.violetOpaque,
                  opacity: 0.08
                })}
              />
            </ModalContentItem>
          </ModalContent>

          <ActionArea>
            <ActionAreaButton>
              Action
            </ActionAreaButton>
          </ActionArea>
        </ModalContainer>
      </Modal>
    </FlexBox>
  )
}

export default Demo;

API

NameTypesdefaultValue
open
boolean
-
defaultOpen
boolean
-
onOpenChange
(open: boolean) => void
-
children
ReactNode
-
sx
SxProp
-

ModalContainer

NameTypesdefaultValue
as
ElementType
-
variant
"full" | "bottom" | "popup"
"popup"
handle
boolean
-
peekHeight
number
-
sticky
boolean
true
size
"medium" | "large" | "xlarge"
"medium"
resize
"fill" | "fixed" | "hug" | "flexible"
"hug"
snap
"full" | "peek" | "half"
-
defaultSnap
"full" | "half"
"half"
onSnapChange
(snap: "full" | "peek" | "half") => void
-
largestUndimmedSnap
"peek" | "half"
"peek"
enableHalfSnapScroll
boolean
false
children
ReactNode
-
wrapperProps
DefaultComponentProps<{}, 'div'>
-
dimmer
ReactNode
<ModalDimmer />
container
null | Element | DocumentFragment
-
disableOutsideClickClose
boolean
false
disableEscapeKeyDownClose
boolean
false
disableRemoveScroll
boolean
false
disableFocusScope
boolean
false
disableAriaHiddenOthers
boolean
false
disablePortal
boolean
false
forceMount
boolean
false
sx
SxProp
-
xl
Merge<Pick<ModalContainerDefaultProps, "variant" | "size" | "handle" | "resize">, { sx?: CSSInterpolation; }> | undefined
-
lg
Merge<Pick<ModalContainerDefaultProps, "variant" | "size" | "handle" | "resize">, { sx?: CSSInterpolation; }> | undefined
-
md
Merge<Pick<ModalContainerDefaultProps, "variant" | "size" | "handle" | "resize">, { sx?: CSSInterpolation; }> | undefined
-
sm
Merge<Pick<ModalContainerDefaultProps, "variant" | "size" | "handle" | "resize">, { sx?: CSSInterpolation; }> | undefined
-
xs
Merge<Pick<ModalContainerDefaultProps, "variant" | "size" | "handle" | "resize">, { sx?: CSSInterpolation; }> | undefined
-

ModalNavigation

NameTypesdefaultValue
variant
"search" | "normal" | "floating" | "emphasized"
-
trailingContent
ReactNode
<ModalNavigationButton variant="close-button" />
leadingContent
ReactNode
-
toolbar
ReactNode
-
background
boolean
-
children
ReactNode
-
xl
{ sx?: CSSInterpolation; } | undefined
-
lg
{ sx?: CSSInterpolation; } | undefined
-
md
{ sx?: CSSInterpolation; } | undefined
-
sm
{ sx?: CSSInterpolation; } | undefined
-
xs
{ sx?: CSSInterpolation; } | undefined
-
sx
SxProp
-

ModalNavigationButton

NameTypesdefaultValue
as
ElementType
-
variant
"icon-button" | "text-button" | "back-button" | "close-button"
"icon-button"
color
"primary" | "assistive"
"assistive"
disabled
boolean
-
size
number | "small" | "medium" | "large" | "xlarge"
-
background
boolean
-
alternative
boolean
-
children
ReactNode
-
sx
SxProp
-

ModalContent

NameTypesdefaultValue
gap
Property.Gap<string | number> | undefined
"var(--modal-content-margin-y, 24px)"
verticalPadding
"none" | "both" | "top-only" | "bottom-only"
-
horizontalPadding
"none" | "both"
"both"
children
ReactNode
-
sx
SxProp
-
xl
Merge<Pick<ModalContentDefaultProps, "verticalPadding" | "gap" | "horizontalPadding">, { sx?: CSSInterpolation; }> | undefined
-
lg
Merge<Pick<ModalContentDefaultProps, "verticalPadding" | "gap" | "horizontalPadding">, { sx?: CSSInterpolation; }> | undefined
-
md
Merge<Pick<ModalContentDefaultProps, "verticalPadding" | "gap" | "horizontalPadding">, { sx?: CSSInterpolation; }> | undefined
-
sm
Merge<Pick<ModalContentDefaultProps, "verticalPadding" | "gap" | "horizontalPadding">, { sx?: CSSInterpolation; }> | undefined
-
xs
Merge<Pick<ModalContentDefaultProps, "verticalPadding" | "gap" | "horizontalPadding">, { sx?: CSSInterpolation; }> | undefined
-

ModalContentItem

FlexBox 와 동일한 Props를 사용합니다.

ModalHeading

Typography 와 동일한 Props를 사용합니다.

ModalSummary

Typography 와 동일한 Props를 사용합니다.

ModalDescription

Typography 와 동일한 Props를 사용합니다.

© 2026 Wanted Lab, Inc.