fix(ui): align the select chevron symmetrically with the value inset

Both select primitives parked the trailing chevron visibly farther from
the right edge than the value text sits from the left. The shadcn trigger
gets a -2 px optical nudge to offset the chevron glyph's internal
padding. The native select drops the OS-drawn arrow (`appearance-none`)
and paints a chevron background at a 12 px right inset mirroring the left
`px-3` padding, kept on the single `<select>` element so call-site width
and margin classes still compose unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 23:25:52 +02:00
1 parent 0e184e7f1b
commit 646ef164ae
2 files changed
+30 -3

No files matched your search

+22 -2
View File
@@ -37,20 +37,40 @@ import { cn } from "@/lib/utils";
*/
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5; shrink
// to 40 px on sm+ where the pointer precision is higher.
//
// Chevron symmetry: `appearance-none` drops the browser-drawn dropdown arrow,
// whose right inset is OS-controlled and reads visibly farther from the edge
// than the value text sits from the left. We paint our own chevron as a
// background SVG positioned `right 0.75rem center` (12 px) so its inset mirrors
// the left text padding (`px-3`) — matching the shadcn `<SelectTrigger>`.
// Doing it as a background (rather than an overlay element) keeps `<select>` a
// single element, so every call site's width / margin className still composes
// straight onto it. `pe-9` reserves room so a long value never runs under the
// chevron. The chevron glyph is the same path Lucide's `ChevronDownIcon` draws,
// at `currentColor` with 50 % opacity to match the trigger.
const CHEVRON_SVG =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' opacity='0.5'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")";
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
export const NativeSelect = React.forwardRef<
HTMLSelectElement,
NativeSelectProps
>(function NativeSelect({ className, children, ...props }, ref) {
>(function NativeSelect({ className, children, style, ...props }, ref) {
return (
<select
ref={ref}
data-slot="native-select"
className={cn(NATIVE_SELECT_CLASS, className)}
style={
{
"--native-select-chevron": CHEVRON_SVG,
...style,
} as React.CSSProperties
}
{...props}
>
{children}
+8 -1
View File
@@ -56,7 +56,14 @@ function SelectTrigger({
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
{/* `-me-0.5` (-2 px end margin) optically aligns the chevron with the
left text inset. The trigger box is `px-3` (12 px both sides), but
the Lucide chevron glyph carries ~2 px of empty space inside its
16 px bounding box, so the *visible* stroke landed ~2 px farther
from the right edge than the value text sits from the left — the
trigger read lopsided. Pulling the icon box 2 px toward the edge
makes the visible chevron and the value text inset symmetric. */}
<ChevronDownIcon className="-me-0.5 size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);