Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Bottom Sheet

A bottom sheet overlay with a drag handle.

A bottom sheet overlay with a drag handle — for secondary content and quick actions.

Installation

npx touchcn add bottom-sheet

Usage

<button tcnButton (click)="open.set(true)">Open</button>

<tcn-bottom-sheet [(open)]="open">
  <p>Sheet content</p>
</tcn-bottom-sheet>
import { TcnBottomSheet } from '@/components/ui/bottom-sheet';

<TcnBottomSheet open={open} onOpenChange={setOpen} title="Options">
  <p>Sheet content</p>
</TcnBottomSheet>
<script setup lang="ts">
import { TcnBottomSheet } from '@/components/ui/bottom-sheet';
</script>

<template>
  <TcnBottomSheet v-model:open="open" title="Options">
    <p>Sheet content</p>
  </TcnBottomSheet>
</template>

Props

Prop Type Default Description
open boolean false Open state (two-way bound on Angular).
onOpenChange (open: boolean) => void Open-state callback (React).
title string 'Bottom Sheet' Accessible label announced on open (React).
Source · Angular
export * from './tcn-bottom-sheet';
import { Component, computed, model, output } from '@angular/core';
import { TcnOverlayDirective } from '@touchcn/angular/overlay';

@Component({
  selector: 'tcn-bottom-sheet',
  imports: [TcnOverlayDirective],
  template: `
    <div class="fixed inset-0 z-50" [class.pointer-events-none]="!open()" [attr.data-state]="state()">
      <div class="tcn-overlay-backdrop absolute inset-0 bg-black/40" (click)="overlay.dismiss()"></div>
      <div
        #overlay="tcnOverlay"
        tcnOverlay
        [(open)]="open"
        (dismissed)="closed.emit()"
        class="tcn-overlay-panel tcn-bottom-sheet-panel tcn-material absolute inset-x-0 bottom-0 rounded-b-none"
      >
        <div class="flex justify-center py-2.5">
          <span class="tcn-grabber"></span>
        </div>
        <div class="px-4 text-[var(--color-on-surface)]">
          <ng-content />
        </div>
      </div>
    </div>
  `,
})
export class TcnBottomSheet {
  readonly open = model(false);
  readonly closed = output<void>();

  protected readonly state = computed(() => (this.open() ? 'open' : 'closed'));
}
Source · React
export * from './tcn-bottom-sheet';
import type { ReactNode } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useOverlayPresence } from '@touchcn/react';

export interface TcnBottomSheetProps {
  open: boolean;
  onOpenChange(open: boolean): void;
  /** Accessible label (visually hidden) announced when the sheet opens. */
  title?: string;
  children?: ReactNode;
}

/**
 * Bottom sheet overlay. Built on Radix Dialog for the portal, focus trap,
 * scroll lock, Escape and outside-click dismissal, and the modal ARIA
 * contract. The panel and backdrop carry the exact `tcn-*` classes so
 * `theme.css` styles them identically to the Angular component.
 */
export function TcnBottomSheet({ open, onOpenChange, title = 'Bottom Sheet', children }: TcnBottomSheetProps) {
  const { present, active, panelRef } = useOverlayPresence(open);
  if (!present) {
    return null;
  }
  const state = active ? 'open' : 'closed';
  return (
    <Dialog.Root open onOpenChange={(next) => !next && onOpenChange(false)}>
      <Dialog.Portal>
        <div className="fixed inset-0 z-50" data-state={state}>
          <Dialog.Overlay className="tcn-overlay-backdrop absolute inset-0 bg-black/40" />
          <Dialog.Content
            ref={panelRef}
            aria-describedby={undefined}
            className="tcn-overlay-panel tcn-bottom-sheet-panel tcn-material absolute inset-x-0 bottom-0 rounded-b-none"
          >
            <Dialog.Title className="sr-only">{title}</Dialog.Title>
            <div className="flex justify-center py-2.5">
              <span className="tcn-grabber" />
            </div>
            <div className="px-4 text-[var(--color-on-surface)]">{children}</div>
          </Dialog.Content>
        </div>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
Source · Vue
<script setup lang="ts">
import { computed } from 'vue';
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui';
import { useOverlayPresence } from '@touchcn/vue';

withDefaults(defineProps<{ title?: string }>(), { title: 'Bottom Sheet' });

/** Two-way bound open state (`v-model:open`). */
const open = defineModel<boolean>('open', { default: false });

const { present, active, setPanel } = useOverlayPresence(open);
const state = computed(() => (active.value ? 'open' : 'closed'));

const onRekaUpdate = (next: boolean): void => {
  if (!next) {
    open.value = false;
  }
};
</script>

<template>
  <DialogRoot v-if="present" :open="true" @update:open="onRekaUpdate">
    <DialogPortal>
      <div class="fixed inset-0 z-50" :data-state="state">
        <DialogOverlay class="tcn-overlay-backdrop absolute inset-0 bg-black/40" />
        <DialogContent
          :ref="setPanel"
          :aria-describedby="undefined"
          class="tcn-overlay-panel tcn-bottom-sheet-panel tcn-material absolute inset-x-0 bottom-0 rounded-b-none"
        >
          <DialogTitle class="sr-only">{{ title }}</DialogTitle>
          <div class="flex justify-center py-2.5">
            <span class="tcn-grabber" />
          </div>
          <div class="px-4 text-[var(--color-on-surface)]">
            <slot />
          </div>
        </DialogContent>
      </div>
    </DialogPortal>
  </DialogRoot>
</template>
export { default as TcnBottomSheet } from './TcnBottomSheet.vue';

Last updated on July 24, 2026

Was this page helpful?