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';