feat(web): give pressable surfaces a quick press response

Buttons (every variant except link, skipped while loading), chips, tiles,
nav items, rail and tab-bar icons now dip to 0.97 on press; document,
template, application and suggestion cards dip to 0.98. Full-width settings
and conversation rows darken to the press colour instead of shrinking.
Joined button groups cancel the scale so their seams stay closed, and the
switch thumb stretches toward its travel while held.

Document and template cards transitioned transform, but Tailwind v4 lifts
with the translate property, so their hover lift jumped; they now list
translate and scale. A new document card keeps its accent ring while
hovered, and letter template cards lift like resume template cards.
This commit is contained in:
Amruth Pillai
2026-09-29 12:40:58 +02:00
parent d9979bbc8d
commit 0bc128ff3a
15 changed files with 38 additions and 29 deletions
@@ -21,7 +21,7 @@ export function ApplicationCard({ application, onClick, withMenu = false, draggi
return (
<div
className={cn(
"group relative grid gap-2 rounded-[10px] border border-line bg-surface p-3 shadow-e1 transition-colors duration-quick hover:border-line-2",
"group relative grid gap-2 rounded-[10px] border border-line bg-surface p-3 shadow-e1 transition-[border-color,scale] duration-quick ease-enter hover:border-line-2 active:not-has-[[data-slot=button]:active]:scale-[0.98]",
dragging && "rotate-1 shadow-e3",
)}
>
@@ -358,7 +358,7 @@ function EmptyState({ document, disabled, onPick }: EmptyStateProps) {
type="button"
disabled={disabled}
onClick={() => onPick(suggestion.label)}
className="flex min-h-11 w-full items-center gap-3 rounded-lg border border-line px-3 py-2 text-start transition-colors duration-quick hover:bg-hover disabled:opacity-60"
className="flex min-h-11 w-full items-center gap-3 rounded-lg border border-line px-3 py-2 text-start transition-[background-color,scale] duration-quick ease-enter hover:bg-hover enabled:active:scale-[0.98] disabled:opacity-60"
>
<Icon name={suggestion.icon} size={20} className="shrink-0 text-ink-2" />
<span className="grid min-w-0">
@@ -489,7 +489,10 @@ function ConversationRow({ thread, current, detail, onOpen, onDelete }: Conversa
type="button"
aria-current={current ? "true" : undefined}
onClick={onOpen}
className={cn("grid min-w-0 flex-1 rounded-lg px-3 py-2 text-start", current && "bg-sunken")}
className={cn(
"grid min-w-0 flex-1 rounded-lg px-3 py-2 text-start transition-colors duration-quick ease-enter active:bg-press",
current && "bg-sunken",
)}
>
<span className="truncate font-medium text-sm">{thread.title}</span>
<span className="truncate text-ink-3 text-xs">{detail}</span>
@@ -498,7 +501,7 @@ function ConversationRow({ thread, current, detail, onOpen, onDelete }: Conversa
icon="close"
label={t`Delete ${thread.title}`}
size="icon-sm"
className="me-1 text-ink-3 opacity-0 transition-[opacity,background-color,border-color,color,filter] focus-visible:opacity-100 group-hover/row:opacity-100"
className="me-1 text-ink-3 opacity-0 transition-[opacity,background-color,border-color,color,filter,scale] focus-visible:opacity-100 group-hover/row:opacity-100"
onClick={onDelete}
/>
</li>
@@ -669,7 +669,7 @@ export function Composer(props: ComposerProps) {
disabled={!streaming && (!hasText || disabled)}
onClick={streaming ? props.onStop : submit}
className={cn(
"grid size-[34px] shrink-0 place-items-center rounded-lg transition-colors duration-quick",
"grid size-[34px] shrink-0 place-items-center rounded-lg transition-[background-color,color,scale] duration-quick ease-enter enabled:active:scale-[0.97]",
streaming ? "bg-ink text-bg" : hasText ? "bg-accent text-on-accent" : "bg-sunken text-ink-3",
)}
>
@@ -125,8 +125,9 @@ export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
document={document}
label={document.name}
className={cn(
"relative block aspect-page overflow-hidden rounded-[6px] shadow-[0_0_0_1px_var(--line),var(--shadow-1)] transition-[transform,box-shadow] duration-quick hover:-translate-y-0.5 hover:shadow-[0_0_0_1px_var(--line),var(--shadow-2)]",
isNew && "shadow-[0_0_0_2px_var(--accent),var(--shadow-1)]",
"relative block aspect-page overflow-hidden rounded-[6px] shadow-[0_0_0_1px_var(--line),var(--shadow-1)] transition-[translate,scale,box-shadow] duration-quick ease-enter hover:-translate-y-0.5 hover:shadow-[0_0_0_1px_var(--line),var(--shadow-2)] active:scale-[0.98]",
isNew &&
"shadow-[0_0_0_2px_var(--accent),var(--shadow-1)] hover:shadow-[0_0_0_2px_var(--accent),var(--shadow-2)]",
document.trashedAt && "pointer-events-none",
)}
>
@@ -160,7 +160,7 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
})
}
className={cn(
"h-7 rounded-full border px-3 text-[13px] transition-colors duration-quick",
"h-7 rounded-full border px-3 text-[13px] transition-[background-color,border-color,color,scale] duration-quick ease-enter active:scale-[0.97]",
active
? "border-accent bg-accent-soft text-accent-text"
: "border-line-2 text-ink-2 hover:bg-hover",
@@ -282,7 +282,7 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
const file = event.dataTransfer.files[0];
if (file) void importFile(file);
}}
className="flex items-start gap-4 rounded-xl border-[1.5px] border-line-2 border-dashed p-5 text-start transition-colors duration-quick hover:border-accent hover:bg-accent-soft"
className="flex items-start gap-4 rounded-xl border-[1.5px] border-line-2 border-dashed p-5 text-start transition-[background-color,border-color,scale] duration-quick ease-enter hover:border-accent hover:bg-accent-soft active:scale-[0.98]"
>
<span className="grid size-11 shrink-0 place-items-center rounded-[10px] bg-sunken text-ink-2">
<Icon name="upload_file" size={24} />
@@ -420,7 +420,7 @@ function ChoiceTile({ icon, title, description, onClick, disabled }: ChoiceTileP
type="button"
disabled={disabled}
onClick={onClick}
className="flex items-start gap-3 rounded-xl border border-line p-4 text-start transition-colors duration-quick hover:border-line-2 hover:bg-hover disabled:opacity-60"
className="flex items-start gap-3 rounded-xl border border-line p-4 text-start transition-[background-color,border-color,scale] duration-quick ease-enter hover:border-line-2 hover:bg-hover enabled:active:scale-[0.97] disabled:opacity-60"
>
<Icon name={icon} size={22} className="mt-0.5 text-ink-2" />
<span className="grid gap-0.5">
@@ -578,7 +578,7 @@ function CopyForJob({ initialSourceId, initialJobId, onBack, onCreated }: CopyFo
setName(null);
}}
className={cn(
"flex h-8 items-center gap-1.5 rounded-full border px-3 text-[13px] transition-colors duration-quick",
"flex h-8 items-center gap-1.5 rounded-full border px-3 text-[13px] transition-[background-color,border-color,color,scale] duration-quick ease-enter active:scale-[0.97]",
jobId === option.id
? "border-accent bg-accent-soft text-accent-text"
: "border-line-2 text-ink-2 hover:bg-hover",
@@ -182,7 +182,7 @@ export function ColorGroup() {
value={accent.hex}
aria-label={accent.label}
title={accent.label}
className="aspect-square cursor-pointer rounded-full transition-shadow duration-quick data-checked:shadow-[0_0_0_2px_var(--surface),0_0_0_4px_var(--swatch)]"
className="aspect-square cursor-pointer rounded-full transition-[box-shadow,scale] duration-quick ease-enter active:scale-[0.97] data-checked:shadow-[0_0_0_2px_var(--surface),0_0_0_4px_var(--swatch)]"
style={{ backgroundColor: accent.hex, "--swatch": accent.hex } as CSSProperties}
/>
))}
@@ -190,7 +190,7 @@ function TemplateCard({ id, data, selected, onPreview }: TemplateCardProps) {
>
<span
className={cn(
"relative block aspect-page overflow-hidden rounded-md border border-line bg-white shadow-e1 transition-[transform,box-shadow] duration-quick group-hover/card:-translate-y-0.5 group-hover/card:shadow-e2",
"relative block aspect-page overflow-hidden rounded-md border border-line bg-white shadow-e1 transition-[translate,scale,box-shadow] duration-quick ease-enter group-hover/card:-translate-y-0.5 group-hover/card:shadow-e2 group-active/card:scale-[0.98]",
selected && "ring-2 ring-accent ring-offset-2 ring-offset-surface",
)}
>
@@ -73,7 +73,7 @@ function ThemeTiles() {
<label
key={option.value}
className={cn(
"flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border border-line-2 bg-surface font-medium text-sm transition-[border-color,box-shadow] duration-quick hover:bg-hover has-focus-visible:outline-2 has-focus-visible:outline-accent",
"flex h-16 cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border border-line-2 bg-surface font-medium text-sm transition-[background-color,border-color,box-shadow,scale] duration-quick ease-enter hover:bg-hover active:scale-[0.97] has-focus-visible:outline-2 has-focus-visible:outline-accent",
checked && "border-accent shadow-[0_0_0_3px_var(--accent-soft)]",
)}
>
+3 -2
View File
@@ -25,7 +25,8 @@ function Row({ icon, label, value }: RowProps) {
);
}
const rowClass = "flex h-14 items-center gap-3 px-3.5 font-medium text-base";
const rowClass =
"flex h-14 items-center gap-3 px-3.5 font-medium text-base transition-colors duration-quick ease-enter active:bg-press";
/** Phones: the Account tab is the settings root, three rows showing their current values. */
export function SettingsRoot() {
@@ -66,7 +67,7 @@ export function SettingsRoot() {
href="https://docs.rxresu.me"
target="_blank"
rel="noopener noreferrer"
className="flex h-12 items-center rounded-[14px] border border-line bg-surface px-3.5 font-medium"
className="flex h-12 items-center rounded-[14px] border border-line bg-surface px-3.5 font-medium transition-colors duration-quick ease-enter active:bg-press"
>
<Trans>Help & docs</Trans>
</a>
@@ -152,7 +152,7 @@ export function LetterDesignPanel() {
>
<span
className={cn(
"relative block aspect-[1/1.414] overflow-hidden rounded-md border bg-white transition-shadow duration-quick",
"relative block aspect-[1/1.414] overflow-hidden rounded-md border bg-white transition-[translate,scale,box-shadow] duration-quick ease-enter group-hover/template:-translate-y-0.5 group-active/template:scale-[0.98]",
selected
? "border-accent shadow-[0_0_0_2px_var(--accent)]"
: "border-line group-hover/template:shadow-e2",
@@ -182,7 +182,7 @@ function NavLink({ item, current }: { item: NavItem; current: boolean }) {
to={item.to}
aria-current={current ? "page" : undefined}
className={cn(
"flex h-[34px] items-center gap-2.5 rounded-lg px-2.5 text-sm transition-colors duration-quick",
"flex h-[34px] items-center gap-2.5 rounded-lg px-2.5 text-sm transition-[background-color,color,scale] duration-quick ease-enter active:scale-[0.97]",
current ? "bg-sunken font-medium text-ink" : "text-ink-2 hover:bg-hover hover:text-ink",
)}
>
@@ -214,7 +214,7 @@ function Rail() {
type="button"
aria-label={t`Search or run…`}
onClick={() => openPalette(true)}
className="grid size-10 place-items-center rounded-lg text-ink-2 transition-colors hover:bg-hover"
className="grid size-10 place-items-center rounded-lg text-ink-2 transition-[background-color,scale] duration-quick ease-enter hover:bg-hover active:scale-[0.97]"
/>
</RailTip>
<nav aria-label={t`App`} className="grid gap-1">
@@ -225,7 +225,7 @@ function Rail() {
aria-label={item.label}
aria-current={isCurrent(item.to) ? "page" : undefined}
className={cn(
"grid size-10 place-items-center rounded-lg transition-colors duration-quick",
"grid size-10 place-items-center rounded-lg transition-[background-color,color,scale] duration-quick ease-enter active:scale-[0.97]",
isCurrent(item.to) ? "bg-sunken text-ink" : "text-ink-2 hover:bg-hover",
)}
/>
@@ -238,7 +238,7 @@ function Rail() {
<Link
to={trash.to}
aria-label={t`Trash`}
className="grid size-10 place-items-center rounded-lg text-ink-2 transition-colors hover:bg-hover"
className="grid size-10 place-items-center rounded-lg text-ink-2 transition-[background-color,scale] duration-quick ease-enter hover:bg-hover active:scale-[0.97]"
/>
</RailTip>
)}
@@ -302,21 +302,21 @@ function MobileTabs() {
<Link
to="/dashboard"
aria-current={isCurrent("/dashboard") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5"
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
>
{tab("description", t`Documents`, isCurrent("/dashboard"))}
</Link>
<Link
to="/dashboard/applications"
aria-current={isCurrent("/dashboard/applications") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5"
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
>
{tab("work", t`Applications`, isCurrent("/dashboard/applications"))}
</Link>
<button
type="button"
onClick={() => openDialog("document.new", undefined)}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5"
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
>
<span className="grid h-[26px] w-[34px] place-items-center rounded-full bg-accent text-on-accent">
<Icon name="add" size={20} />
@@ -329,7 +329,7 @@ function MobileTabs() {
<Link
to="/dashboard/settings"
aria-current={isCurrent("/dashboard/settings") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5"
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5 transition-[scale] duration-quick ease-enter active:scale-[0.97]"
>
{tab("account_circle", t`Account`, isCurrent("/dashboard/settings"))}
</Link>
+1 -1
View File
@@ -3,7 +3,7 @@ import { cva } from "class-variance-authority";
import { cn } from "@reactive-resume/utils/style";
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 *:active:scale-100! has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
+6 -2
View File
@@ -5,7 +5,7 @@ import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
const buttonVariants = cva(
"group/button touch-target relative inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap border border-transparent font-medium text-sm transition-[background-color,border-color,color,filter] duration-quick ease-enter disabled:pointer-events-none aria-busy:cursor-progress [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group/button touch-target relative inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap border border-transparent font-medium text-sm transition-[background-color,border-color,color,filter,scale] duration-quick ease-enter disabled:pointer-events-none aria-busy:cursor-progress [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -27,7 +27,11 @@ const buttonVariants = cva(
"icon-lg": "size-11 rounded-lg",
},
},
compoundVariants: [{ variant: "ghost", size: "default", className: "px-3" }],
compoundVariants: [
{ variant: "ghost", size: "default", className: "px-3" },
// Press feedback; a text link doesn't press, and a loading button blocks activation.
{ variant: ["primary", "secondary", "ghost", "danger"], className: "not-aria-busy:active:scale-[0.97]" },
],
defaultVariants: {
variant: "primary",
size: "default",
+1 -1
View File
@@ -9,7 +9,7 @@ const trackClassName =
"relative inline-flex shrink-0 items-center rounded-full bg-line-2 transition-colors duration-quick group-data-checked/switch:bg-accent group-data-disabled/switch:opacity-50 data-[size=default]:h-5 data-[size=default]:w-8 data-[size=touch]:h-[26px] data-[size=touch]:w-11";
const thumbClassName =
"pointer-events-none block rounded-full bg-white shadow-e1 transition-transform duration-quick ease-enter group-data-[size=default]/track:ms-0.5 group-data-[size=touch]/track:ms-0.5 group-data-[size=default]/track:size-4 group-data-[size=touch]/track:size-[22px] group-data-[size=default]/track:data-checked:translate-x-3 group-data-[size=touch]/track:data-checked:translate-x-[18px] rtl:group-data-[size=default]/track:data-checked:-translate-x-3 rtl:group-data-[size=touch]/track:data-checked:-translate-x-[18px]";
"pointer-events-none block origin-left data-checked:origin-right rtl:origin-right rtl:data-checked:origin-left group-active/switch:not-data-disabled:scale-x-[1.15] rounded-full bg-white shadow-e1 transition-transform duration-quick ease-enter group-data-[size=default]/track:ms-0.5 group-data-[size=touch]/track:ms-0.5 group-data-[size=default]/track:size-4 group-data-[size=touch]/track:size-[22px] group-data-[size=default]/track:data-checked:translate-x-3 group-data-[size=touch]/track:data-checked:translate-x-[18px] rtl:group-data-[size=default]/track:data-checked:-translate-x-3 rtl:group-data-[size=touch]/track:data-checked:-translate-x-[18px]";
function SwitchTrack({ size }: { size: SwitchSize }) {
return (