Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

FAB

A floating action button for the screen's primary action.

A floating action button for a screen’s single most important action. Android renders the MD3 primary-container surface with elevation and a ripple. iOS has no FAB idiom, so it renders as a floating circular glass button — pragmatic and consistent with the rest of the iOS chrome.

Installation

npx touchcn add fab

Usage

<button tcnFab aria-label="Add">
  <svg><!-- icon --></svg>
</button>

<button tcnFab extended>
  <svg><!-- icon --></svg>
  <span>Compose</span>
</button>
import { TcnFab } from '@/components/ui/fab';

<TcnFab aria-label="Add">
  <PlusIcon />
</TcnFab>

<TcnFab extended>
  <PlusIcon />
  <span>Compose</span>
</TcnFab>
<script setup lang="ts">
import { TcnFab } from '@/components/ui/fab';
</script>

<template>
  <TcnFab aria-label="Add">
    <PlusIcon />
  </TcnFab>

  <TcnFab extended>
    <PlusIcon />
    <span>Compose</span>
  </TcnFab>
</template>

Drop the FAB inline, or add the tcn-fab-fixed helper class to dock it in the bottom-right corner, clearing the tabbar and the home-indicator safe area automatically.

Props

Prop Type Default Description
size 'regular' | 'small' 'regular' 56 / 40 px circular button.
extended boolean false Widen into an icon + label pill.
disabled boolean false Disable interaction.
Source · Angular
export * from './tcn-fab';
import { booleanAttribute, computed, Directive, ElementRef, inject, input } from '@angular/core';
import { cn } from '@touchcn/core';
import { TcnButtonDirective } from '@touchcn/angular/button';
import { TcnRippleDirective } from '@touchcn/angular/ripple';

export type TcnFabSize = 'regular' | 'small';

const base =
  'tcn-fab inline-flex select-none items-center justify-center transition disabled:pointer-events-none disabled:opacity-40';

const sizeClass: Record<TcnFabSize, string> = {
  regular: 'tcn-fab-regular',
  small: 'tcn-fab-small',
};

/**
 * Floating action button. MD3 renders the primary-container tonal surface with
 * elevation and a ripple; iOS has no FAB idiom, so the cascade skins it as a
 * floating circular glass button (the shared chrome material) — pragmatic and
 * documented. `extended` widens it into an icon + label pill. Add the
 * `tcn-fab-fixed` helper class to dock it bottom-right above the tabbar
 * clearance, or drop it inline. The ripple attaches on both platforms and is
 * MD-only by cascade; disabled forwarding rides on the shared button directive.
 */
@Directive({
  selector: 'button[tcnFab]',
  hostDirectives: [{ directive: TcnButtonDirective, inputs: ['disabled'] }, TcnRippleDirective],
  host: { '[class]': 'classes()' },
})
export class TcnFab {
  private readonly authored = inject(ElementRef<HTMLElement>).nativeElement.getAttribute('class') ?? '';

  readonly size = input<TcnFabSize>('regular');
  readonly extended = input(false, { transform: booleanAttribute });

  protected readonly classes = computed(() =>
    cn(base, sizeClass[this.size()], this.extended() ? 'tcn-fab-extended' : '', this.authored),
  );
}
Source · React
export * from './tcn-fab';
import { forwardRef, useCallback } from 'react';
import type { ComponentPropsWithoutRef, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@touchcn/core';
import { mergeRefs, useRipple } from '@touchcn/react';

export type TcnFabSize = 'regular' | 'small';

const base =
  'tcn-fab inline-flex select-none items-center justify-center transition disabled:pointer-events-none disabled:opacity-40';

const sizeClass: Record<TcnFabSize, string> = {
  regular: 'tcn-fab-regular',
  small: 'tcn-fab-small',
};

export interface TcnFabProps extends ComponentPropsWithoutRef<'button'> {
  size?: TcnFabSize;
  /** Widen into an icon + label pill (MD3 extended FAB). */
  extended?: boolean;
  /** Render the child element instead of a `<button>` (e.g. an anchor). */
  asChild?: boolean;
}

/**
 * Floating action button. MD3 renders the primary-container tonal surface with
 * elevation and a ripple; iOS has no FAB idiom, so the cascade skins it as a
 * floating circular glass button (the shared chrome material) — pragmatic and
 * documented. `extended` widens it into an icon + label pill. Add the
 * `tcn-fab-fixed` helper class to dock it bottom-right above the tabbar
 * clearance, or drop it inline. Emits the same `tcn-fab` classes as the Angular
 * directive; the ripple attaches on both platforms and is MD-only by cascade.
 */
export const TcnFab = forwardRef<HTMLButtonElement, TcnFabProps>(function TcnFab(
  { size = 'regular', extended = false, asChild = false, className, type, disabled, onPointerDown, onPointerCancel, onClick, children, ...props },
  forwardedRef,
) {
  const { hostRef, hostClassName, rippleHandlers } = useRipple<HTMLButtonElement>();
  const Comp = asChild ? Slot : 'button';

  const handlePointerDown = useCallback(
    (event: ReactPointerEvent<HTMLButtonElement>) => {
      onPointerDown?.(event);
      if (!event.defaultPrevented) rippleHandlers.onPointerDown(event);
    },
    [onPointerDown, rippleHandlers],
  );
  const handlePointerCancel = useCallback(
    (event: ReactPointerEvent<HTMLButtonElement>) => {
      onPointerCancel?.(event);
      if (!event.defaultPrevented) rippleHandlers.onPointerCancel(event);
    },
    [onPointerCancel, rippleHandlers],
  );
  const handleClick = useCallback(
    (event: ReactMouseEvent<HTMLButtonElement>) => {
      onClick?.(event);
      if (!event.defaultPrevented) rippleHandlers.onClick(event);
    },
    [onClick, rippleHandlers],
  );

  return (
    <Comp
      ref={mergeRefs(forwardedRef, hostRef)}
      data-slot="fab"
      type={asChild ? type : (type ?? 'button')}
      disabled={disabled}
      aria-disabled={disabled || undefined}
      className={cn(base, sizeClass[size], extended ? 'tcn-fab-extended' : '', hostClassName, className)}
      onPointerDown={handlePointerDown}
      onPointerCancel={handlePointerCancel}
      onClick={handleClick}
      {...props}
    >
      {children}
    </Comp>
  );
});
Source · Vue
<script lang="ts">
export type TcnFabSize = 'regular' | 'small';

const base =
  'tcn-fab inline-flex select-none items-center justify-center transition disabled:pointer-events-none disabled:opacity-40';

const sizeClass: Record<TcnFabSize, string> = {
  regular: 'tcn-fab-regular',
  small: 'tcn-fab-small',
};
</script>

<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from 'reka-ui';
import { cn } from '@touchcn/core';
import { useRipple } from '@touchcn/vue';

/**
 * Floating action button. MD3 renders the primary-container tonal surface with
 * elevation and a ripple; iOS has no FAB idiom, so the cascade skins it as a
 * floating circular glass button (the shared chrome material). `extended`
 * widens it into an icon + label pill. Add the `tcn-fab-fixed` helper class to
 * dock it bottom-right above the tabbar clearance, or drop it inline. Emits the
 * same `tcn-fab` classes as the Angular / React counterparts; the ripple
 * attaches on both platforms and is MD-only by cascade.
 */
const props = withDefaults(
  defineProps<{
    size?: TcnFabSize;
    /** Widen into an icon + label pill (MD3 extended FAB). */
    extended?: boolean;
    /** Render the slotted element instead of a `<button>` (e.g. an anchor). */
    asChild?: boolean;
    type?: 'button' | 'submit' | 'reset';
    disabled?: boolean;
  }>(),
  { size: 'regular', extended: false, asChild: false },
);

const { setHost, hostClassName, onPointerdown, onPointercancel, onClick } = useRipple();

const classes = computed(() =>
  cn(base, sizeClass[props.size], props.extended ? 'tcn-fab-extended' : '', hostClassName),
);
</script>

<template>
  <Primitive
    :ref="setHost"
    as="button"
    :as-child="asChild"
    data-slot="fab"
    :type="asChild ? type : (type ?? 'button')"
    :disabled="disabled"
    :aria-disabled="disabled || undefined"
    :class="classes"
    @pointerdown="onPointerdown"
    @pointercancel="onPointercancel"
    @click="onClick"
  >
    <slot />
  </Primitive>
</template>
export { default as TcnFab } from './TcnFab.vue';
export type { TcnFabSize } from './TcnFab.vue';

Last updated on July 24, 2026

Was this page helpful?