Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

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

Last updated on July 24, 2026

Was this page helpful?