Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Button

A platform-adaptive button with Material and iOS styling.

A platform-adaptive button — Material ripple and tonal fills on Android, iOS control styling on iOS.

Installation

npx touchcn add button

Usage

<button tcnButton variant="primary" size="md">Save</button>
<button tcnButton variant="ghost">Cancel</button>
import { TcnButton } from '@/components/ui/button';

<TcnButton variant="primary" size="md">Save</TcnButton>
<TcnButton variant="ghost">Cancel</TcnButton>
<script setup lang="ts">
import { TcnButton } from '@/components/ui/button';
</script>

<template>
  <TcnButton variant="primary" size="md">Save</TcnButton>
  <TcnButton variant="ghost">Cancel</TcnButton>
</template>

Props

Prop Type Default Description
variant 'primary' | 'secondary' | 'outlined' | 'elevated' | 'ghost' | 'destructive' 'primary' Visual style.
size 'sm' | 'md' | 'lg' 'md' Control size.
Source · Angular
export * from './tcn-button';
import { 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 TcnButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outlined' | 'elevated';
export type TcnButtonSize = 'sm' | 'md' | 'lg';

const base =
  'tcn-btn inline-flex select-none items-center justify-center gap-2 rounded-full transition disabled:pointer-events-none disabled:opacity-40';

const variantClass: Record<TcnButtonVariant, string> = {
  primary: 'tcn-btn-primary',
  secondary: 'tcn-btn-secondary',
  ghost: 'tcn-btn-ghost',
  destructive: 'tcn-btn-destructive',
  outlined: 'tcn-btn-outlined',
  elevated: 'tcn-btn-elevated',
};

// Height forks per platform (MD default 40dp, iOS 44pt), so it lives in the
// theme chrome keyed by these size classes rather than a shared `h-*` utility —
// a consumer's own height utility still wins over the chrome.
const sizeClass: Record<TcnButtonSize, string> = {
  sm: 'tcn-btn-sm px-3.5 text-sm',
  md: 'tcn-btn-md px-5 text-base',
  lg: 'tcn-btn-lg px-6 text-lg',
};

@Directive({
  selector: 'button[tcnButton]',
  hostDirectives: [{ directive: TcnButtonDirective, inputs: ['disabled'] }, TcnRippleDirective],
  host: { '[class]': 'classes()' },
})
export class TcnButton {
  private readonly authored = inject(ElementRef<HTMLElement>).nativeElement.getAttribute('class') ?? '';

  readonly variant = input<TcnButtonVariant>('primary');
  readonly size = input<TcnButtonSize>('md');

  protected readonly classes = computed(() =>
    cn(base, variantClass[this.variant()], sizeClass[this.size()], this.authored),
  );
}
Source · React
export * from './tcn-button';
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 TcnButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outlined' | 'elevated';
export type TcnButtonSize = 'sm' | 'md' | 'lg';

const base =
  'tcn-btn inline-flex select-none items-center justify-center gap-2 rounded-full transition disabled:pointer-events-none disabled:opacity-40';

const variantClass: Record<TcnButtonVariant, string> = {
  primary: 'tcn-btn-primary',
  secondary: 'tcn-btn-secondary',
  ghost: 'tcn-btn-ghost',
  destructive: 'tcn-btn-destructive',
  outlined: 'tcn-btn-outlined',
  elevated: 'tcn-btn-elevated',
};

// Height forks per platform (MD default 40dp, iOS 44pt), so it lives in the
// theme chrome keyed by these size classes rather than a shared `h-*` utility —
// a consumer's own height utility still wins over the chrome.
const sizeClass: Record<TcnButtonSize, string> = {
  sm: 'tcn-btn-sm px-3.5 text-sm',
  md: 'tcn-btn-md px-5 text-base',
  lg: 'tcn-btn-lg px-6 text-lg',
};

export interface TcnButtonProps extends ComponentPropsWithoutRef<'button'> {
  variant?: TcnButtonVariant;
  size?: TcnButtonSize;
  /** Render the child element instead of a `<button>` (shadcn-style). */
  asChild?: boolean;
}

export const TcnButton = forwardRef<HTMLButtonElement, TcnButtonProps>(function TcnButton(
  { variant = 'primary', size = 'md', 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="button"
      type={asChild ? type : (type ?? 'button')}
      disabled={disabled}
      aria-disabled={disabled || undefined}
      className={cn(base, variantClass[variant], sizeClass[size], hostClassName, className)}
      onPointerDown={handlePointerDown}
      onPointerCancel={handlePointerCancel}
      onClick={handleClick}
      {...props}
    >
      {children}
    </Comp>
  );
});
Source · Vue
<script lang="ts">
export type TcnButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outlined' | 'elevated';
export type TcnButtonSize = 'sm' | 'md' | 'lg';

const base =
  'tcn-btn inline-flex select-none items-center justify-center gap-2 rounded-full transition disabled:pointer-events-none disabled:opacity-40';

const variantClass: Record<TcnButtonVariant, string> = {
  primary: 'tcn-btn-primary',
  secondary: 'tcn-btn-secondary',
  ghost: 'tcn-btn-ghost',
  destructive: 'tcn-btn-destructive',
  outlined: 'tcn-btn-outlined',
  elevated: 'tcn-btn-elevated',
};

// Height forks per platform (MD default 40dp, iOS 44pt), so it lives in the
// theme chrome keyed by these size classes rather than a shared `h-*` utility —
// a consumer's own height utility still wins over the chrome.
const sizeClass: Record<TcnButtonSize, string> = {
  sm: 'tcn-btn-sm px-3.5 text-sm',
  md: 'tcn-btn-md px-5 text-base',
  lg: 'tcn-btn-lg px-6 text-lg',
};
</script>

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

const props = withDefaults(
  defineProps<{
    variant?: TcnButtonVariant;
    size?: TcnButtonSize;
    /** Render the slotted element instead of a `<button>` (shadcn-style). */
    asChild?: boolean;
    type?: 'button' | 'submit' | 'reset';
    disabled?: boolean;
  }>(),
  { variant: 'primary', size: 'md', asChild: false },
);

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

const classes = computed(() => cn(base, variantClass[props.variant], sizeClass[props.size], hostClassName));
</script>

<template>
  <Primitive
    :ref="setHost"
    as="button"
    :as-child="asChild"
    data-slot="button"
    :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 TcnButton } from './TcnButton.vue';
export type { TcnButtonVariant, TcnButtonSize } from './TcnButton.vue';

Last updated on July 24, 2026

Was this page helpful?