Installation
Set up your project. Then add components with the shadcn CLI, or copy the files.
Requirements
- React 19 and TypeScript
- Tailwind CSS v4
- A shadcn theme with CSS variables
vip/ui does not support Tailwind CSS v3, JavaScript-only projects, or the shadcn --no-css-variables mode.
Components do not import from next/. You can use them in any React 19 app, such as a Vite app. Blocks install as Next.js App Router routes, so they need Next.js.
Set up your project
If your project does not have a components.json file, run the shadcn init command. For framework steps, see the shadcn installation guide (opens in a new tab).
pnpm dlx shadcn@latest initMake sure that components.json has these values. tailwind.css is the path to your global CSS file.
{ "tsx": true, "tailwind": { "css": "src/app/globals.css", "cssVariables": true }}Add a theme
Components use the shadcn color tokens, such as --primary and --border. Your shadcn theme works with no changes.
To use the vip/ui colors:
- Open Themes and select a theme.
- Copy its CSS into your global CSS file, after the
:rootand.darkrules.
Each theme sets the color tokens to steps of the color scales. To use the scales in your own styles, such as bg-blue-3, copy their CSS from Colors.
Add a component
Copy the command from a component page, such as Button. Run it from your project root.
pnpm dlx shadcn@latest add https://vip-ui.dev/r/vip-button.jsonThe CLI does these steps:
- Adds the component file to a
vip-ui/folder in your components alias. - Adds the files that the component needs, such as
utils.tsandpopover.tsx. Each file is a separate registry item. All components use the same copy. - Installs the packages that the component uses.
- Adds the Tailwind plugin and theme variables to your global CSS file. It keeps the values that you already set.
- Does not change the files in your shadcn
components/ui/folder.
Add the namespace
Add vip/ui to the registries in your components.json. Keep your other values.
{ "registries": { "@vip-ui": "https://vip-ui.dev/r/{name}.json" }}Then add items by name. Item names start with vip-.
pnpm dlx shadcn@latest add @vip-ui/vip-buttonInstall manually
Use these steps if you do not use the CLI.
1Install the Tailwind plugin
The plugin adds Tailwind variants for React Aria states, such as
selected:andpressed:.npmyarnpnpmbunpnpm add -D tailwindcss-react-aria-components2Add the theme styles
Add this CSS to your global CSS file one time. It loads the plugin and adds status colors, shadows, and easing curves. All other colors come from your shadcn theme or a vip/ui theme. The radius comes from your shadcn theme.
globals.css@plugin "tailwindcss-react-aria-components";:root { --success: oklch(0.43 0.12 155); --success-subtle: oklch(0.955 0.035 155); --success-foreground: oklch(0.32 0.09 155); --warning: oklch(0.54 0.13 75); --warning-subtle: oklch(0.965 0.045 85); --warning-foreground: oklch(0.36 0.09 75); --highlight: color-mix(in oklab, var(--foreground) 6%, transparent); --shadow-card: 0 1px 2px -1px oklch(0 0 0 / 0.08), 0 3px 8px -4px oklch(0 0 0 / 0.07); --shadow-float: 0 2px 4px -2px oklch(0 0 0 / 0.1), 0 8px 18px -9px oklch(0 0 0 / 0.11); --shadow-inset: inset 0 1px 2px oklch(0 0 0 / 0.045);}.dark { --success: oklch(0.74 0.13 155); --success-subtle: oklch(0.24 0.045 155); --success-foreground: oklch(0.86 0.085 155); --warning: oklch(0.78 0.11 75); --warning-subtle: oklch(0.27 0.045 75); --warning-foreground: oklch(0.89 0.07 75); --highlight: color-mix(in oklab, var(--foreground) 6%, transparent); --shadow-card: 0 1px 2px -1px oklch(0 0 0 / 0.24), 0 3px 8px -4px oklch(0 0 0 / 0.25); --shadow-float: 0 2px 4px -2px oklch(0 0 0 / 0.3), 0 8px 18px -9px oklch(0 0 0 / 0.34); --shadow-inset: inset 0 1px 2px oklch(0 0 0 / 0.15);}@theme inline { --color-success: var(--success); --color-success-subtle: var(--success-subtle); --color-success-foreground: var(--success-foreground); --color-warning: var(--warning); --color-warning-subtle: var(--warning-subtle); --color-warning-foreground: var(--warning-foreground); --color-highlight: var(--highlight); --ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1); --ease-spring: linear( 0, 0.1251, 0.3554, 0.5737, 0.7411, 0.8551, 0.9259, 0.9666, 0.988, 0.9981, 1.002, 1 );}Show more3Install the packages
Open the Manual tab on the component page. Install the packages that it shows.
4Copy the files
Copy the component file into a
vip-ui/folder in your components folder. Then add the required files that the Manual tab shows. Do not change relative imports, such as./utils. Examine existing files before you replace them.
Import a component
Import the component from the vip-ui/ folder.
import { Button } from "@/components/vip-ui/button";export function Example() { return <Button>Save changes</Button>;}Demo code imports from @/components/vip-ui/. If your alias is different, change the import paths. A demo can use other vip/ui components. Add those components too.
Shared files
Many components use these files. The CLI adds them for you. For a manual installation, add each file one time.
utils.ts
Merges class names with tailwind-variants.
import { type CnOptions, cn as mergeClasses } from "tailwind-variants";export function cn(...classes: CnOptions): string { return mergeClasses(...classes) ?? "";}motion.ts
Sets the easing curves and springs of animations.
import type { Transition } from "motion/react";export const duration = { fast: 0.15, base: 0.2, slow: 0.25, reveal: 0.45,} as const;export const maxStagger = 0.6;export const easeOut = [0.23, 1, 0.32, 1] as const;export const easeInOut = [0.77, 0, 0.175, 1] as const;export const easeSheet = [0.32, 0.72, 0, 1] as const;export const springSnappy = { type: "spring", duration: 0.24, bounce: 0,} as const satisfies Transition;export const springLayout = { type: "spring", duration: 0.3, bounce: 0,} as const satisfies Transition;export const springPop = { type: "spring", visualDuration: 0.28, bounce: 0.12,} as const satisfies Transition;export const loop = { spin: 0.8, pulse: 1.1, sweep: 1.6,} as const;export function transitionFor( reduceMotion: boolean | null, transition: Transition,): Transition { return reduceMotion ? { duration: 0 } : transition;}size.tsx
Sets one size scale for buttons, toggles, and their groups.
"use client";import { createContext, type ReactNode, useContext } from "react";export type Size = "xs" | "sm" | "md" | "lg" | "xl";const SizeContext = createContext<Size | undefined>(undefined);export function useSize(size?: Size) { const inherited = useContext(SizeContext); return size ?? inherited;}export function SizeProvider({ size, children,}: { size: Size | undefined; children: ReactNode;}) { return <SizeContext value={size}>{children}</SizeContext>;}button-styles.tsx
Sets the variants and sizes of buttons and button links.
import { Children, type ReactNode } from "react";import { tv, type VariantProps } from "tailwind-variants";export function buttonLabel(children: ReactNode): ReactNode { const parts: ReactNode[] = []; let text = ""; const flush = () => { if (text.trim()) { parts.push( <span key={`label-${parts.length}`} data-slot="button-label" className="min-w-0 truncate" > {text} </span>, ); } text = ""; }; for (const child of Children.toArray(children)) { if (typeof child === "string" || typeof child === "number") { text += child; } else { flush(); parts.push(child); } } flush(); return parts;}export const touchTarget = "relative pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,var(--touch-target,2.75rem))] pointer-coarse:after:-translate-y-1/2";export const buttonStyles = tv({ base: "inline-flex min-w-0 max-w-full shrink-0 cursor-default items-center justify-center whitespace-nowrap rounded-md font-medium tracking-[-0.01em] outline-offset-4 outline-transparent transition-[color,background-color,border-color,box-shadow,scale,outline-color,outline-offset] duration-150 ease-out-quint disabled:cursor-default disabled:opacity-50 disabled:shadow-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring motion-reduce:outline-offset-2 [&_svg]:pointer-events-none [&_svg]:shrink-0", variants: { variant: { default: "bg-primary text-primary-foreground shadow-[var(--shadow-card)] hover:bg-primary/90 pressed:bg-primary/90", secondary: "bg-secondary text-secondary-foreground hover:bg-border/70 pressed:bg-border/70", outline: "border border-border bg-card text-foreground shadow-[var(--shadow-card)] hover:bg-muted pressed:bg-muted", ghost: "text-foreground hover:bg-muted in-data-[item-highlight=under]:hover:bg-transparent pressed:bg-muted aria-[pressed=true]:bg-accent aria-[pressed=true]:text-accent-foreground aria-[pressed=true]:hover:bg-accent", minimal: "text-muted-foreground hover:bg-muted in-data-[item-highlight=under]:hover:bg-transparent hover:text-foreground pressed:bg-muted pressed:text-foreground", destructive: "bg-destructive text-background shadow-[var(--shadow-card)] hover:bg-destructive/90 pressed:bg-destructive/90", }, size: { xs: `h-7 gap-1.5 rounded-sm px-2.5 text-xs [&_svg:not([class*='size-'])]:size-3.5 ${touchTarget}`, sm: `h-9 gap-2 px-3.5 text-sm [&_svg:not([class*='size-'])]:size-4 ${touchTarget}`, md: "h-11 gap-2 px-5 text-sm [&_svg:not([class*='size-'])]:size-4", lg: "h-12 gap-2 px-6 text-sm [&_svg:not([class*='size-'])]:size-4", xl: "h-14 gap-2.5 rounded-lg px-7 text-base [&_svg:not([class*='size-'])]:size-5", }, iconOnly: { true: "cursor-pointer px-0", }, }, compoundVariants: [ { size: "xs", iconOnly: true, class: "w-7" }, { size: "sm", iconOnly: true, class: "w-9" }, { size: "md", iconOnly: true, class: "w-11 [&_svg:not([class*='size-'])]:size-4.5", }, { size: "lg", iconOnly: true, class: "w-12 [&_svg:not([class*='size-'])]:size-5", }, { size: "xl", iconOnly: true, class: "w-14 [&_svg:not([class*='size-'])]:size-6", }, ], defaultVariants: { variant: "default", size: "md" },});export const buttonLinkStyles = tv({ extend: buttonStyles, base: "cursor-pointer",});export type ButtonVariant = NonNullable< VariantProps<typeof buttonStyles>["variant"]>;field-styles.tsx
Sets the theme styles of text and segmented fields.
import type { Size } from "./size";export const fieldSizeStyles = { xs: "[--field-h:1.75rem] [--field-px:0.5rem] [--field-inset:0.125rem] [--field-radius:var(--radius-sm)] [--field-text:0.75rem] [--field-icon:0.875rem]", sm: "[--field-h:2.25rem] [--field-px:0.75rem] [--field-inset:0.1875rem] [--field-radius:var(--radius-md)] [--field-text:0.875rem] [--field-icon:1rem]", md: "[--field-h:2.75rem] [--field-px:0.875rem] [--field-inset:0.25rem] [--field-radius:var(--radius-lg)] [--field-text:0.875rem] [--field-icon:1rem]", lg: "[--field-h:3rem] [--field-px:0.875rem] [--field-inset:0.25rem] [--field-radius:var(--radius-lg)] [--field-text:0.875rem] [--field-icon:1rem]", xl: "[--field-h:3.5rem] [--field-px:1rem] [--field-inset:0.3125rem] [--field-radius:var(--radius-xl)] [--field-text:1rem] [--field-icon:1.25rem]",} satisfies Record<Size, string>;export const fieldHeight = "min-h-[var(--field-h,3rem)]";export const fieldInnerHeight = "min-h-[calc(var(--field-h,3rem)-0.25rem)]";export const fieldPaddingX = "px-[var(--field-px,0.875rem)]";export const fieldRadius = "rounded-[var(--field-radius,var(--radius-lg))]";export const fieldText = "text-base sm:text-[length:var(--field-text,0.875rem)] sm:[line-height:var(--tw-leading,1.25rem)]";export const fieldIcon = "[&_svg:not([class*='size-'])]:size-[var(--field-icon,1rem)]";export const fieldLabelStyles = "text-sm font-medium text-foreground [overflow-wrap:anywhere]";export const fieldDescriptionStyles = "text-xs leading-5 text-muted-foreground [overflow-wrap:anywhere]";export const fieldErrorStyles = "text-xs leading-5 text-destructive [overflow-wrap:anywhere] transition-[opacity,translate] duration-200 ease-out-quint starting:opacity-0 motion-safe:starting:-translate-y-1";export const choiceCardStyles = "min-h-12 rounded-lg bg-card px-3.5 py-2.5 shadow-[var(--shadow-card)] inset-ring-1 inset-ring-border outline-offset-4 outline-transparent transition-[background-color,box-shadow,outline-color,outline-offset] duration-150 ease-out-quint has-[[data-hovered]]:bg-[color-mix(in_oklab,var(--muted)_60%,var(--card))] has-[[data-focus-visible]]:outline-2 has-[[data-focus-visible]]:outline-offset-2 has-[[data-focus-visible]]:outline-ring motion-reduce:outline-offset-2 selected:inset-ring-2 selected:inset-ring-[color-mix(in_oklab,var(--primary)_60%,var(--card))] invalid:inset-ring-destructive/60 invalid:selected:inset-ring-destructive disabled:has-[[data-hovered]]:bg-card forced-colors:border forced-colors:selected:border-2 forced-colors:selected:border-[Highlight] forced-colors:selected:px-[calc(--spacing(3.5)-1px)] forced-colors:selected:py-[calc(--spacing(2.5)-1px)]";export const fieldInputStyles = `${fieldHeight} ${fieldRadius} ${fieldText} w-full cursor-text border border-input bg-card text-foreground shadow-[var(--shadow-card)] outline-none transition-[color,background-color,border-color,box-shadow] duration-150 ease-out-quint placeholder:text-muted-foreground/80 hover:border-primary/45 focus:border-ring focus:ring-1 focus:ring-ring invalid:border-destructive invalid:hover:border-destructive invalid:focus:border-destructive invalid:focus:ring-destructive [&[readonly]]:bg-muted/40 [&[readonly]]:hover:border-input disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 disabled:hover:border-input`;export const segmentedFieldStyles = `${fieldHeight} ${fieldRadius} flex min-w-0 items-center border border-input bg-card shadow-[var(--shadow-card)] transition-[color,background-color,border-color,box-shadow] duration-150 ease-out-quint hover:border-primary/45 focus-within:border-ring focus-within:hover:border-ring focus-within:ring-1 focus-within:ring-ring group-invalid:border-destructive group-invalid:hover:border-destructive group-invalid:focus-within:border-destructive group-invalid:focus-within:hover:border-destructive group-invalid:focus-within:ring-destructive group-data-[readonly]:bg-muted/40 group-data-[readonly]:hover:border-input group-disabled:bg-muted group-disabled:opacity-60 group-disabled:hover:border-input`;export const fieldTriggerStyles = `${fieldIcon} group/trigger grid size-[calc(var(--field-h,3rem)-2px-2*var(--field-inset,0.25rem))] shrink-0 cursor-pointer place-items-center rounded-[max(0.1875rem,calc(var(--field-radius,var(--radius-lg))-1px-var(--field-inset,0.25rem)))] text-muted-foreground outline-none transition-[color,background-color,scale,opacity] duration-150 ease-out-quint hover:bg-muted hover:text-foreground pressed:bg-muted pressed:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground motion-safe:pressed:scale-[0.92] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted-foreground`;export const fieldActionStyles = "h-[calc(var(--field-h,3rem)-2px-2*var(--field-inset,0.25rem))] rounded-[max(0.1875rem,calc(var(--field-radius,var(--radius-lg))-1px-var(--field-inset,0.25rem)))] px-3 text-[length:var(--field-text,0.875rem)] motion-safe:pressed:scale-[0.96] [&_svg:not([class*='size-'])]:size-[var(--field-icon,1rem)]";export const fieldTriggerEndStyles = "absolute inset-y-0 end-[calc(1px+var(--field-inset,0.25rem))] my-auto";export const fieldTriggerInputPadding = "pe-[calc(var(--field-h,3rem)+0.25rem)]";export const fieldChevronStyles = "grid place-items-center transition-[rotate] duration-350 ease-spring group-aria-expanded/trigger:rotate-180 motion-reduce:transition-none";date-segment.tsx
Shows the editable segments of date and time fields.
"use client";import { DateSegment as AriaDateSegment, composeRenderProps, type DateSegmentProps,} from "react-aria-components";import { cn } from "./utils";const literalSpace = /[   ]/g;export function DateSegment({ className, children, ...props}: DateSegmentProps) { return ( <AriaDateSegment {...props} data-slot="date-segment" className={composeRenderProps(className, (className) => cn( "rounded-sm px-0.5 py-0.5 tabular-nums outline-none caret-transparent transition-colors duration-150 ease-out-quint data-[placeholder]:text-muted-foreground [&:focus]:duration-0 invalid:text-destructive [&[data-type=literal]]:px-0 [&:focus]:bg-primary [&:focus]:text-primary-foreground forced-colors:[&:focus]:bg-[Highlight] forced-colors:[&:focus]:text-[HighlightText] forced-colors:[&:focus]:forced-color-adjust-none", className, ), )} > {children ?? (({ text, type }) => type === "literal" ? text.replace(literalSpace, " ") : text)} </AriaDateSegment> );}collapsible-panel.tsx
Animates a disclosure panel and keeps its content accessible.
"use client";import { DisclosurePanel as AriaDisclosurePanel, composeRenderProps, type DisclosurePanelProps,} from "react-aria-components";import { cn } from "./utils";export const collapsibleChevronStyles = "shrink-0 text-muted-foreground transition-transform duration-250 ease-out-quint group-aria-expanded/trigger:rotate-180 motion-reduce:transition-none";export function CollapsiblePanel({ className, ...props}: DisclosurePanelProps) { return ( <AriaDisclosurePanel data-slot="collapsible-panel" {...props} className={composeRenderProps(className, (className) => cn( "h-(--disclosure-panel-height) overflow-clip transition-[height,opacity] duration-250 ease-out-quint aria-hidden:opacity-0 motion-reduce:transition-none", className, ), )} /> );}item-highlight.tsx
Moves one highlight to the hovered or focused item.
"use client";import { animate, createGeneratorEasing, generateLinearEasing, spring, type Transition, useReducedMotion,} from "motion/react";import { type ReactElement, useLayoutEffect, useRef } from "react";import { duration, easeOut, springSnappy, transitionFor } from "./motion";import { cn } from "./utils";const collectionItems = "[role=option], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=row]";export const itemHighlightRing = "focus-visible:outline-1 focus-visible:outline-solid focus-visible:-outline-offset-1 focus-visible:outline-ring in-data-[item-highlight=ring]:focus-visible:outline-none forced-colors:focus-visible:outline-2 forced-colors:focus-visible:outline-solid forced-colors:focus-visible:-outline-offset-2";const focusVisible = ":is([data-rac][data-focus-visible], :not([data-rac]):focus-visible)";const fade = { duration: duration.fast, ease: easeOut } satisfies Transition;const snap = { duration: 0 } satisfies Transition;const curve = createGeneratorEasing( { bounce: springSnappy.bounce, duration: springSnappy.duration * 1000 }, 100, spring,);const glideTiming = { duration: curve.duration * 1000, easing: generateLinearEasing(curve.ease, curve.duration * 1000), fill: "forwards",} satisfies KeyframeEffectOptions;type Move = "glide" | "snap";type Point = { x: number; y: number };type Box = Point & { width: number; height: number; radius: string };type Layer = | { kind: "hidden" } | { kind: "leaving" } | { kind: "shown"; target: HTMLElement; box: Box; ring: boolean };type Flight = { from: Point; to: Point };type Pointer = Point & { over: EventTarget | null; item: HTMLElement | null;};const translate = ({ x, y }: Point) => `translate(${x}px, ${y}px)`;const position = (animation: Animation, { from, to }: Flight): Point => { const progress = curve.ease( Math.min(1, Number(animation.currentTime ?? 0) / glideTiming.duration), ); return { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress, };};export interface ItemHighlightProps { items?: string; placement?: "over" | "under"; ring?: boolean; className?: string;}export function ItemHighlight({ items = collectionItems, placement = "over", ring = false, className,}: ItemHighlightProps) { const ref = useRef<HTMLSpanElement>(null); const reduceMotion = useReducedMotion(); useLayoutEffect(() => { const layer = ref.current; const container = layer?.parentElement; if (!layer || !container) return; if ( placement === "under" && container.parentElement?.closest( ":has(> [data-slot=item-highlight][data-placement=under])", ) ) return; const rest = placement === "under" ? "under" : "fill"; const sizeTransition = transitionFor(reduceMotion, springSnappy); const focusedItem = `:is(${items})${focusVisible}`; const virtualItem = `:is(${items})[data-focused]`; let focused: HTMLElement | null = null; let last: "pointer" | "focus" = "focus"; let pointer: Pointer | null = null; let state: Layer = { kind: "hidden" }; let flight: Flight | null = null; const glideEffect = new KeyframeEffect(layer, null, glideTiming); const glide = new Animation(glideEffect); const owned = (node: Element | null): node is HTMLElement => node instanceof HTMLElement && node.closest("[data-item-highlight], [data-empty]") === container && !container.hasAttribute("data-empty"); const itemAt = (node: Element, previous: HTMLElement | null) => { const item = node.closest(items); if (!item) return node.querySelector(items) ? previous : null; return owned(item) && !item.matches("[data-disabled], [aria-disabled=true]") ? item : null; }; const live = (item?: HTMLElement | null) => item && container.contains(item) ? item : null; const measure = (target: HTMLElement, origin: Element, radius: string) => { let x = 0; let y = 0; for (let node: HTMLElement | null = target; node && node !== origin; ) { x += node.offsetLeft; y += node.offsetTop; node = node.offsetParent as HTMLElement | null; if (node && node !== origin) { x += node.clientLeft; y += node.clientTop; } } return { x, y, width: target.offsetWidth, height: target.offsetHeight, radius, }; }; const slide = (to: Point, from: Point | null, move: Move) => { if (from && to.x === from.x && to.y === from.y) return; const start = from && (flight ? position(glide, flight) : from); if (!start || move === "snap" || reduceMotion) { glide.cancel(); flight = null; layer.style.transform = translate(to); return; } glideEffect.setKeyframes({ transform: [translate(start), translate(to)], }); glide.currentTime = 0; glide.play(); flight = { from: start, to }; }; const place = (box: Box, from: Box | null, move: Move) => { slide(box, from, move); if (!from || box.width !== from.width || box.height !== from.height) animate( layer, { width: box.width, height: box.height }, from && move === "glide" ? sizeTransition : snap, ); if (!from || box.radius !== from.radius) animate(layer, { borderRadius: box.radius }, snap); }; const paintRing = (on: boolean) => { if (on === (state.kind === "shown" && state.ring)) return; layer.toggleAttribute("data-ring", on); container.setAttribute("data-item-highlight", on ? "ring" : rest); }; const resize = new ResizeObserver((entries) => { const unchanged = entries.every( ({ target, borderBoxSize: [size] }) => state.kind === "shown" && target === state.target && Math.round(size.inlineSize) === state.box.width && Math.round(size.blockSize) === state.box.height, ); if (!unchanged) render("glide"); }); const hide = () => { if (state.kind !== "shown") return; paintRing(false); resize.unobserve(state.target); const leaving: Layer = { kind: "leaving" }; state = leaving; animate(layer, { opacity: 0 }, fade).then(() => { if (state !== leaving) return; state = { kind: "hidden" }; animate(layer, { width: 0, height: 0 }, snap); }); }; const render = (move: Move) => { const origin = layer.offsetParent; const target = origin && (last === "pointer" ? (live(pointer?.item) ?? live(focused)) : (live(focused) ?? live(pointer?.item))); if (!target) return hide(); const kept = state.kind === "shown" && state.target === target ? state : null; const same = kept !== null; const box = measure( target, origin, kept?.box.radius ?? getComputedStyle(target).borderRadius, ); place( box, state.kind === "shown" ? state.box : null, same ? "snap" : move, ); if (state.kind !== "shown") animate(layer, { opacity: 1 }, fade); if (!same) { if (state.kind === "shown") resize.unobserve(state.target); resize.observe(target); } const onTarget = ring && target === focused && target.matches(focusVisible); paintRing(onTarget); state = { kind: "shown", target, box, ring: onTarget }; }; const first = (selector: string) => { for (const node of container.querySelectorAll(selector)) if (owned(node)) return node; return null; }; const syncFocus = () => { const next = first(focusedItem) ?? (container.contains(document.activeElement) ? null : first(virtualItem)); if (next !== focused) { focused = next; last = "focus"; } render("glide"); }; const onPointerMove = (event: PointerEvent) => { if (event.pointerType === "touch") return; if (pointer?.over === event.target && last === "pointer") { pointer.x = event.clientX; pointer.y = event.clientY; return; } pointer = { x: event.clientX, y: event.clientY, over: event.target, item: event.target instanceof Element ? itemAt(event.target, pointer?.item ?? null) : null, }; last = "pointer"; render("glide"); }; const onPointerLeave = (event: PointerEvent) => { if (event.pointerType === "touch") return; pointer = null; render("glide"); }; const onScroll = () => { if (!pointer) return; const over = document.elementFromPoint(pointer.x, pointer.y); if (over === pointer.over) return; const item = over && container.contains(over) ? itemAt(over, pointer.item) : null; const moved = item !== pointer.item; pointer = { ...pointer, over, item }; if (moved) render("snap"); }; const mutations = new MutationObserver(syncFocus); container.setAttribute("data-item-highlight", rest); mutations.observe(container, { subtree: true, childList: true, attributeFilter: ["data-focus-visible", "data-focused"], }); resize.observe(container); container.addEventListener("pointermove", onPointerMove); container.addEventListener("pointerleave", onPointerLeave); container.addEventListener("focusin", syncFocus); container.addEventListener("focusout", syncFocus); document.addEventListener("scroll", onScroll, { capture: true, passive: true, }); syncFocus(); return () => { mutations.disconnect(); resize.disconnect(); container.removeEventListener("pointermove", onPointerMove); container.removeEventListener("pointerleave", onPointerLeave); container.removeEventListener("focusin", syncFocus); container.removeEventListener("focusout", syncFocus); document.removeEventListener("scroll", onScroll, { capture: true }); container.removeAttribute("data-item-highlight"); layer.removeAttribute("data-ring"); glide.cancel(); animate(layer, { opacity: 0, width: 0, height: 0 }, snap); }; }, [items, placement, ring, reduceMotion]); return ( <span ref={ref} aria-hidden="true" data-slot="item-highlight" data-placement={placement} className={cn( "pointer-events-none absolute top-0 left-0 bg-highlight opacity-0 forced-colors:hidden data-ring:outline-1 data-ring:outline-solid data-ring:-outline-offset-1 data-ring:outline-[color-mix(in_oklab,var(--ring),var(--foreground)_20%)]", placement === "over" ? "z-10" : "-z-10", className, )} /> );}type DivRender<T> = ( props: React.JSX.IntrinsicElements["div"], renderProps: T,) => ReactElement;export function withItemHighlight<T>( render: DivRender<T> | undefined, highlight?: ItemHighlightProps,): DivRender<T> { return (props, renderProps) => { const next = { ...props, className: cn("relative isolate", props.className), children: ( <> <ItemHighlight {...highlight} /> {props.children} </> ), }; return render ? render(next, renderProps) : <div {...next} />; };}