feat: smoother hover/select feedback on Checkbox, RadioGroup, Select

- RadioGroupItem and SelectItem had no transition class at all, so
  their checked/highlighted background changes snapped instantly.
  Added transition-colors to both.
- Added hover:border-ring to Checkbox and RadioGroupItem so unchecked
  items get visible mouse-hover feedback, not just focus-visible.
- Wrapped the Checkbox checkmark and RadioGroup dot in a small
  Transition (fade-in/zoom-in on check, fade-out/zoom-out on uncheck)
  instead of popping in/out instantly, using the animation-duration-*
  utility (not duration-*) to avoid the transition:all bug fixed
  earlier in this session.

Verified via headless Chromium: no console errors, hover border colors
resolve to the ring token, Select items show a real transition-colors
(not "all") with the expected 150ms duration.
This commit is contained in:
Matthew McPeak 2026-07-14 10:42:53 -04:00
parent a49f216c43
commit da4fd17934
3 changed files with 26 additions and 16 deletions

View file

@ -34,17 +34,22 @@ function toggle() {
:aria-required="required"
:data-state="checked ? 'checked' : 'unchecked'"
:disabled="disabled"
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
:class="cn('border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring', props.class)"
@click="toggle"
>
<span
v-if="checked"
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-50 animation-duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-50 animation-duration-100"
>
<slot>
<CheckIcon />
</slot>
</span>
<span
v-if="checked"
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current"
>
<slot>
<CheckIcon />
</slot>
</span>
</Transition>
</button>
</template>

View file

@ -46,16 +46,21 @@ onBeforeUnmount(() => {
:disabled="disabled || context.disabled.value"
:class="
cn(
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50',
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors hover:border-ring',
props.class,
)
"
@click="onClick"
>
<span v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
<slot>
<CircleIcon class="fill-primary-foreground text-primary-foreground absolute top-1/2 left-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2" />
</slot>
</span>
<Transition
enter-active-class="animate-in fade-in-0 zoom-in-50 animation-duration-100"
leave-active-class="animate-out fade-out-0 zoom-out-50 animation-duration-100"
>
<span v-if="isChecked" data-slot="radio-group-indicator" class="flex size-4 items-center justify-center">
<slot>
<CircleIcon class="fill-primary-foreground text-primary-foreground absolute top-1/2 left-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2" />
</slot>
</span>
</Transition>
</button>
</template>

View file

@ -44,7 +44,7 @@ onMounted(() => {
tabindex="-1"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 transition-colors',
props.class,
)
"