fix(ui): draw one focus ring, not two

src/index.css already declared "one visible, consistent focus treatment",
but shadcn's primitives ship a `focus-visible:ring-[3px]` of their own, so
every button and input actually drew two: a soft translucent halo hugging
the border, then a hard 2px outline 2px further out.

The global rule is the one that stays, because it also covers the links and
bare table buttons that never go through a primitive. The rings come out of
button.tsx and input.tsx instead.

Its `border-radius: 2px` goes too. That was squaring the corners of every
control with no radius utility of its own — the rule-history triggers in
Settings measured `rad=0`, so a focused one drew a sharp rectangle. The
outline now follows each element's own radius.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 22:57:59 +02:00
co-authored by Claude Opus 5
parent f4223573e4
commit f85b967a9d
3 changed files with 18 additions and 6 deletions
+6 -2
View File
@@ -5,13 +5,17 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// No `focus-visible:ring-*` here. src/index.css draws ONE focus outline for
// the whole app, on every focusable thing including the plain buttons and
// links that never come through this file; shadcn's ring would sit inside it
// as a second, softer halo, 2px in from the line. One indicator, one place.
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"bg-destructive text-white hover:bg-destructive/90 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
+3 -2
View File
@@ -9,8 +9,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
// The focus outline is src/index.css's, for the whole app at once —
// see the note in button.tsx.
"aria-invalid:border-destructive",
className
)}
{...props}
+9 -2
View File
@@ -173,11 +173,18 @@
}
/* One visible, consistent focus treatment. Keyboard users get a ring on the
accent colour, which clears 3:1 on every surface in both themes. */
accent colour, which clears 3:1 on every surface in both themes.
Deliberately the ONLY focus indicator: shadcn's primitives ship a second,
softer `ring` box-shadow of their own, and the two together read as a halo
inside a line. Those are stripped in button.tsx and input.tsx instead of
dropping this, because this one rule covers links, table buttons and
anything else that never goes through a primitive.
It sets no border-radius on purpose — an earlier version pinned 2px here,
which squared the corners of every control that had no radius utility of
its own. The outline follows each element's own radius. */
:focus-visible {
outline: 2px solid var(--budget-accent);
outline-offset: 2px;
border-radius: 2px;
}
}