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