From 7185e539c898449d80e83aae534840cccbb02b05 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 5 Jun 2026 12:00:35 +0200 Subject: [PATCH] feat(ui): add Button press microinteraction - Add targeted transition recipe for shared Button states - Add press and reduced-motion behavior - Cover link and menu motion exceptions --- ui/components/shadcn/button/button.test.tsx | 49 +++++++++++++++++++++ ui/components/shadcn/button/button.tsx | 10 ++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/ui/components/shadcn/button/button.test.tsx b/ui/components/shadcn/button/button.test.tsx index 975a1a35f8..c3d145aab5 100644 --- a/ui/components/shadcn/button/button.test.tsx +++ b/ui/components/shadcn/button/button.test.tsx @@ -32,4 +32,53 @@ describe("Button", () => { "text-xs", ); }); + + it("applies the shared press and reduced-motion contract to button-like variants", () => { + // Given + render(); + + // When + const button = screen.getByRole("button", { name: "Start scan" }); + + // Then + expect(button).toHaveClass( + "transition-[background-color,border-color,color,box-shadow,transform,scale]", + "duration-150", + "ease-out", + "active:scale-[0.98]", + "motion-reduce:active:scale-100", + "motion-reduce:transform-none", + "motion-reduce:transition-none", + ); + expect(button).not.toHaveClass("transition-all"); + }); + + it("keeps link buttons from scaling on press", () => { + // Given + render(); + + // When + const button = screen.getByRole("button", { name: "Open details" }); + + // Then + expect(button).toHaveClass("active:scale-100"); + expect(button).not.toHaveClass("active:scale-[0.98]"); + }); + + it("keeps menu buttons on the shared targeted transition recipe", () => { + // Given + render(); + + // When + const button = screen.getByRole("button", { name: "Open menu" }); + + // Then + expect(button).toHaveClass( + "transition-[background-color,border-color,color,box-shadow,transform,scale]", + "duration-200", + "active:scale-[0.98]", + "motion-reduce:active:scale-100", + ); + expect(button).not.toHaveClass("transition-all"); + }); }); diff --git a/ui/components/shadcn/button/button.tsx b/ui/components/shadcn/button/button.tsx index b2a251d562..570303fc33 100644 --- a/ui/components/shadcn/button/button.tsx +++ b/ui/components/shadcn/button/button.tsx @@ -5,7 +5,7 @@ import type { ComponentProps } from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-[background-color,border-color,color,box-shadow,transform,scale] duration-150 ease-out active:scale-[0.98] disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary motion-reduce:active:scale-100 motion-reduce:transform-none motion-reduce:transition-none outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0", { variants: { variant: { @@ -21,13 +21,13 @@ const buttonVariants = cva( "border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50", ghost: "border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50", - link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent", + link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover active:scale-100 disabled:bg-transparent", // Menu variant like secondary but more padding and the back is almost transparent - menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200", + menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200", "menu-active": - "backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200", + "backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200", "menu-inactive": - "text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-border-neutral-secondary/50 transition-all duration-200", + "text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 focus-visible:ring-border-neutral-secondary/50 duration-200", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3",