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