Composants/Boutons
Apple Button
Un bouton inspiré des Human Interface Guidelines d'Apple.
BetaWhoa UI est en bêta : certains éléments peuvent encore changer. Un bug ? Écrivez-moi à hello@ugolin-olle.com.
Apple Button
Loading state
../../apps/web/examples/button-apple.tsx
import { AppleButton } from '@/components/ui/ugolin/button-apple';function ButtonAppleExample() { return ( <section className="flex flex-col space-y-10"> <h2 className="text-xl font-semibold tracking-tight">Apple Button</h2> <section className="grid grid-cols-2 gap-10 md:grid-cols-3"> <AppleButton variant="default">Default</AppleButton> <AppleButton variant="primary">Primary</AppleButton> <AppleButton variant="secondary">Secondary</AppleButton> <AppleButton variant="destructive">Delete</AppleButton> <AppleButton variant="outline">Outline</AppleButton> <AppleButton variant="ghost">Ghost</AppleButton> <AppleButton variant="link">Documentation</AppleButton> </section> <h2 className="text-xl font-semibold tracking-tight">Loading state</h2> <section className="grid grid-cols-2 gap-6 md:grid-cols-3"> <AppleButton variant="primary" loading> Save changes </AppleButton> </section> </section> );}export default ButtonAppleExample;Installation#
Via shadcn — copie le code source
$pnpm dlx shadcn@latest add https://ugolin-olle.com/r/button-apple.jsonInstallation manuelle
../button-apple/index.tsx
'use client';import { Slot } from '@radix-ui/react-slot';import { cva, type VariantProps } from 'class-variance-authority';import { ArrowUpRight, LoaderCircle } from 'lucide-react';import { motion, type HTMLMotionProps } from 'motion/react';import * as React from 'react';import { cn } from '@/lib/utils';export const appleButtonVariants = cva( cn( 'group relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2', 'rounded-[var(--radius)] text-xs font-medium whitespace-nowrap transition-all duration-200 outline-none sm:text-sm', 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 'disabled:pointer-events-none disabled:opacity-50', 'aria-disabled:pointer-events-none aria-disabled:opacity-50', "[&_svg:not([class*='size-'])]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", ), { variants: { variant: { default: cn( 'rounded-full', 'bg-[#f2f2f7] dark:bg-[#2c2c2e]', 'text-[#000000] dark:text-white', 'font-semibold', 'transition-all duration-200', 'hover:bg-[#e5e5ea] dark:hover:bg-[#3a3a3c]', 'active:scale-[0.97]', ), primary: cn( 'rounded-full', 'bg-[#007aff]', 'text-white', 'font-semibold', 'shadow-none', 'transition-all duration-200', 'hover:bg-[#006ee6]', 'active:scale-[0.97]', ), secondary: cn( 'rounded-full', 'bg-[#e5e5ea] dark:bg-[#3a3a3c]', 'text-[#000000] dark:text-white', 'font-semibold', 'transition-all duration-200', 'hover:bg-[#dcdce1] dark:hover:bg-[#48484a]', 'active:scale-[0.97]', ), destructive: cn( 'rounded-full', 'bg-[#e5e5ea] dark:bg-[#3a3a3c]', 'text-[#ff3b30]', 'font-semibold', 'transition-all duration-200', 'hover:bg-[#dcdce1] dark:hover:bg-[#48484a]', 'active:scale-[0.97]', ), outline: cn( 'rounded-full', 'border border-[#d1d1d6] dark:border-[#48484a]', 'bg-transparent', 'text-black dark:text-white', 'font-semibold', 'transition-all duration-200', 'hover:bg-[#f2f2f7] dark:hover:bg-[#2c2c2e]', 'active:scale-[0.97]', ), ghost: cn( 'rounded-full', 'bg-transparent', 'text-black dark:text-white', 'font-semibold', 'transition-all duration-200', 'hover:bg-[#f2f2f7] dark:hover:bg-[#2c2c2e]', 'active:scale-[0.97]', ), link: cn( 'h-auto rounded-none p-0', 'text-[#007aff]', 'font-normal', 'hover:underline', ), }, size: { default: 'h-8 gap-1.5 px-2.5 text-xs sm:text-sm has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-[0.7rem] sm:text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-xs sm:text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: 'h-9 gap-1.5 px-2.5 text-sm sm:text-base has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', icon: 'size-8', 'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', 'icon-lg': 'size-9', }, }, defaultVariants: { variant: 'default', size: 'default', }, },);type MotionButtonProps = Omit<HTMLMotionProps<'button'>, 'children'>;type NativeButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;export interface AppleButtonProps extends NativeButtonProps, VariantProps<typeof appleButtonVariants> { asChild?: boolean; leadingIcon?: React.ReactNode; trailingIcon?: React.ReactNode; loading?: boolean;}const motionOnlyProps = [ 'whileTap', 'whileHover', 'whileFocus', 'whileDrag', 'drag', 'layout', 'transition', 'initial', 'animate', 'exit',];function removeMotionProps(props: Record<string, unknown>) { return Object.fromEntries( Object.entries(props).filter(([key]) => !motionOnlyProps.includes(key)), );}export function AppleButton({ className, variant, size, asChild = false, leadingIcon, trailingIcon, loading = false, disabled, children, ...props}: AppleButtonProps) { const isDisabled = Boolean(disabled) || loading; const leading = leadingIcon ? ( <span data-icon="inline-start" className={cn('inline-flex shrink-0 items-center', loading && 'opacity-0')} > {leadingIcon} </span> ) : null; const isLinkArrow = variant === 'link' && !trailingIcon; const trailing = isLinkArrow ? ( <ArrowUpRight data-icon="inline-end" className={cn( 'size-4', 'translate-x-0 opacity-0', 'transition-all duration-200', 'group-hover:translate-x-1', 'group-hover:-translate-y-1', 'group-hover:opacity-100', loading && '!opacity-0', )} /> ) : trailingIcon ? ( <span data-icon="inline-end" className={cn('inline-flex shrink-0 items-center', loading && 'opacity-0')} > {trailingIcon} </span> ) : null; const content = ( <> {leading} <span className={cn(loading && 'opacity-0')}>{children}</span> {trailing} {loading && ( <span aria-hidden="true" className="absolute inset-0 flex items-center justify-center"> <LoaderCircle className="size-4 animate-spin" /> </span> )} </> ); const classNames = cn( appleButtonVariants({ variant, size, }), className, ); if (asChild) { const slotProps = { ...props, disabled: isDisabled, 'aria-disabled': isDisabled || undefined, 'aria-busy': loading || undefined, }; return ( <Slot className={classNames} {...(slotProps as React.ComponentPropsWithoutRef<'button'>)}> {content} </Slot> ); } const motionProps = removeMotionProps(props as Record<string, unknown>) as MotionButtonProps; return ( <motion.button whileTap={isDisabled ? undefined : { scale: 0.97 }} transition={{ type: 'spring', stiffness: 500, damping: 30, }} className={classNames} disabled={isDisabled} aria-disabled={isDisabled || undefined} aria-busy={loading || undefined} {...motionProps} > {content} </motion.button> );}Une variante du bouton reprenant l'esthétique Apple (coins entièrement arrondis, contraste
doux, effet d'échelle au clic), avec le même support asChild (Radix Slot), icônes gauche/droite
et état loading que le bouton de base.