Action Sheet
A set of choices presented from the bottom of the screen.
A set of choices presented from the bottom of the screen, with an optional destructive action.
Installation
npx touchcn add action-sheet
Usage
<tcn-action-sheet
[(open)]="open"
[actions]="[{ label: 'Share' }, { label: 'Delete', destructive: true }]"
/>import { TcnActionSheet } from '@/components/ui/action-sheet';
<TcnActionSheet
open={open}
onOpenChange={setOpen}
actions={[{ label: 'Share' }, { label: 'Delete', destructive: true }]}
onSelect={(index) => handle(index)}
/><script setup lang="ts">
import { TcnActionSheet } from '@/components/ui/action-sheet';
</script>
<template>
<TcnActionSheet
v-model:open="open"
:actions="[{ label: 'Share' }, { label: 'Delete', destructive: true }]"
@select="(index) => handle(index)"
/>
</template>Props
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Open state (two-way bound on Angular). |
onOpenChange |
(open: boolean) => void |
— | Open-state callback (React). |
actions |
TcnActionSheetAction[] |
[] |
Choices — { label, destructive? }. |
cancelLabel |
string |
'Cancel' |
Cancel button label. |
onSelect |
(index: number) => void |
— | Fired with the chosen action index (React). |
Source · Angular
export * from './tcn-action-sheet';import { Component, computed, input, model, output } from '@angular/core';
import { TcnOverlayDirective } from '@touchcn/angular/overlay';
export interface TcnActionSheetAction {
label: string;
destructive?: boolean;
}
@Component({
selector: 'tcn-action-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"
class="tcn-overlay-panel tcn-actionsheet-panel absolute inset-x-0 bottom-0 px-2 pt-2"
>
<!-- iOS: grouped translucent buttons + separate cancel -->
<div class="if-ios mx-auto max-w-md">
<div class="tcn-material overflow-hidden rounded-[var(--radius-lg)] text-center">
@for (action of actions(); track $index) {
<button
type="button"
class="w-full px-4 py-3.5 text-[17px] not-first:shadow-[inset_0_0.5px_0_0_var(--color-separator)] active:opacity-60"
[style.color]="action.destructive ? 'var(--color-destructive)' : 'var(--color-primary)'"
(click)="pick($index)"
>
{{ action.label }}
</button>
}
</div>
<button
type="button"
class="tcn-material mt-2 w-full rounded-[var(--radius-lg)] px-4 py-3.5 text-[17px] font-semibold text-[var(--color-primary)] active:opacity-60"
(click)="close()"
>
{{ cancelLabel() }}
</button>
</div>
<!-- MD: bottom-sheet list -->
<div class="if-md tcn-material overflow-hidden rounded-b-none pb-2">
<div class="flex justify-center py-2.5">
<span class="tcn-grabber"></span>
</div>
@for (action of actions(); track $index) {
<button
type="button"
class="w-full px-6 py-3 text-left text-base active:bg-[color-mix(in_srgb,var(--color-on-surface)_10%,transparent)]"
[style.color]="action.destructive ? 'var(--color-destructive)' : 'var(--color-on-surface)'"
(click)="pick($index)"
>
{{ action.label }}
</button>
}
</div>
</div>
</div>
`,
})
export class TcnActionSheet {
readonly open = model(false);
readonly actions = input<TcnActionSheetAction[]>([]);
readonly cancelLabel = input('Cancel');
readonly selected = output<number>();
protected readonly state = computed(() => (this.open() ? 'open' : 'closed'));
protected pick(index: number): void {
this.selected.emit(index);
this.close();
}
protected close(): void {
this.open.set(false);
}
}Source · React
export * from './tcn-action-sheet';import * as Dialog from '@radix-ui/react-dialog';
import { useOverlayPresence } from '@touchcn/react';
export interface TcnActionSheetAction {
label: string;
destructive?: boolean;
}
export interface TcnActionSheetProps {
open: boolean;
onOpenChange(open: boolean): void;
actions?: TcnActionSheetAction[];
cancelLabel?: string;
onSelect?(index: number): void;
/** Accessible label (visually hidden) announced when the sheet opens. */
title?: string;
}
/**
* A set of choices presented from the bottom of the screen. iOS groups the
* buttons in a translucent card with a separate cancel; MD renders a
* bottom-sheet list. Built on Radix Dialog.
*/
export function TcnActionSheet({
open,
onOpenChange,
actions = [],
cancelLabel = 'Cancel',
onSelect,
title = 'Actions',
}: TcnActionSheetProps) {
const { present, active, panelRef } = useOverlayPresence(open);
const pick = (index: number) => {
onSelect?.(index);
onOpenChange(false);
};
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-actionsheet-panel absolute inset-x-0 bottom-0 px-2 pt-2"
>
<Dialog.Title className="sr-only">{title}</Dialog.Title>
{/* iOS: grouped translucent buttons + separate cancel */}
<div className="if-ios mx-auto max-w-md">
<div className="tcn-material overflow-hidden rounded-[var(--radius-lg)] text-center">
{actions.map((action, index) => (
<button
key={index}
type="button"
className="w-full px-4 py-3.5 text-[17px] not-first:shadow-[inset_0_0.5px_0_0_var(--color-separator)] active:opacity-60"
style={{ color: action.destructive ? 'var(--color-destructive)' : 'var(--color-primary)' }}
onClick={() => pick(index)}
>
{action.label}
</button>
))}
</div>
<button
type="button"
className="tcn-material mt-2 w-full rounded-[var(--radius-lg)] px-4 py-3.5 text-[17px] font-semibold text-[var(--color-primary)] active:opacity-60"
onClick={() => onOpenChange(false)}
>
{cancelLabel}
</button>
</div>
{/* MD: bottom-sheet list */}
<div className="if-md tcn-material overflow-hidden rounded-b-none pb-2">
<div className="flex justify-center py-2.5">
<span className="tcn-grabber" />
</div>
{actions.map((action, index) => (
<button
key={index}
type="button"
className="w-full px-6 py-3 text-left text-base active:bg-[color-mix(in_srgb,var(--color-on-surface)_10%,transparent)]"
style={{ color: action.destructive ? 'var(--color-destructive)' : 'var(--color-on-surface)' }}
onClick={() => pick(index)}
>
{action.label}
</button>
))}
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}Source · Vue
<script lang="ts">
export interface TcnActionSheetAction {
label: string;
destructive?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui';
import { useOverlayPresence } from '@touchcn/vue';
const props = withDefaults(
defineProps<{
actions?: TcnActionSheetAction[];
cancelLabel?: string;
title?: string;
}>(),
{ actions: () => [], cancelLabel: 'Cancel', title: 'Actions' },
);
/** Two-way bound open state (`v-model:open`). */
const open = defineModel<boolean>('open', { default: false });
const emit = defineEmits<{ select: [index: number] }>();
const { present, active, setPanel } = useOverlayPresence(open);
const state = computed(() => (active.value ? 'open' : 'closed'));
const onRekaUpdate = (next: boolean): void => {
if (!next) {
open.value = false;
}
};
const pick = (index: number): void => {
emit('select', index);
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-actionsheet-panel absolute inset-x-0 bottom-0 px-2 pt-2"
>
<DialogTitle class="sr-only">{{ title }}</DialogTitle>
<!-- iOS: grouped translucent buttons + separate cancel -->
<div class="if-ios mx-auto max-w-md">
<div class="tcn-material overflow-hidden rounded-[var(--radius-lg)] text-center">
<button
v-for="(action, index) in props.actions"
:key="index"
type="button"
class="w-full px-4 py-3.5 text-[17px] not-first:shadow-[inset_0_0.5px_0_0_var(--color-separator)] active:opacity-60"
:style="{ color: action.destructive ? 'var(--color-destructive)' : 'var(--color-primary)' }"
@click="pick(index)"
>
{{ action.label }}
</button>
</div>
<button
type="button"
class="tcn-material mt-2 w-full rounded-[var(--radius-lg)] px-4 py-3.5 text-[17px] font-semibold text-[var(--color-primary)] active:opacity-60"
@click="open = false"
>
{{ cancelLabel }}
</button>
</div>
<!-- MD: bottom-sheet list -->
<div class="if-md tcn-material overflow-hidden rounded-b-none pb-2">
<div class="flex justify-center py-2.5">
<span class="tcn-grabber" />
</div>
<button
v-for="(action, index) in props.actions"
:key="index"
type="button"
class="w-full px-6 py-3 text-left text-base active:bg-[color-mix(in_srgb,var(--color-on-surface)_10%,transparent)]"
:style="{ color: action.destructive ? 'var(--color-destructive)' : 'var(--color-on-surface)' }"
@click="pick(index)"
>
{{ action.label }}
</button>
</div>
</DialogContent>
</div>
</DialogPortal>
</DialogRoot>
</template>export { default as TcnActionSheet } from './TcnActionSheet.vue';
export type { TcnActionSheetAction } from './TcnActionSheet.vue';