Item
A row with media, a title, a line and actions, under one sliding highlight.
The idea
Inside an ItemGroup the rows do not paint their own hover: the group owns one highlight and slides it to whichever row you point at or tab to, so the list reads as one thing following your hand rather than tiles blinking. As a link the title is the anchor and its hit area is stretched under the whole row, so the row opens from anywhere while the trailing buttons still take their own presses — a button inside a link would be invalid.
Composes HighlightList and Avatar and Button and Badge.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Avatar, Badge, Item, ItemGroup } from "mihirmodi-ui";
// One highlight for the whole group, sliding to the row you point at.
// The negative margin keeps titles on your column while the bar bleeds.
<ItemGroup label="Recent" className="-mx-3">
<Item
media={<DocIcon />}
title="Q3 pricing brief"
description="Edited by Ada · 2 minutes ago"
to="/docs/q3-pricing"
actions={<RowMenu />}
/>
<Item
media={<Avatar name="Kenji Sato" />}
mediaVariant="bare"
title="Kenji Sato"
description="Left a comment"
to="/people/kenji"
actions={<Badge variant="outline">New</Badge>}
/>
</ItemGroup>Props
| Prop | Type | Notes |
|---|---|---|
| mediaReactNode | ReactNode | An icon, an Avatar or an <img>. |
| mediaVariant"icon" | "thumb" | "bare" | "icon" | "thumb" | "bare"= "icon" | Icon tile, cropped thumbnail, or left as is. |
| title / descriptionReactNode | ReactNode | The row's name and one line under it. |
| actionsReactNode | ReactNode | Trailing badges or buttons; they stay clickable over a linked row. |
| to / href / onClickstring / string / () => void | string / string / () => void | Makes the row a destination or an action. |
| ItemGroup barClassNamestring | string= "bg-gray-100" | `bg-highlight` on a grey or glass surface. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { Children, createContext, isValidElement, useCallback, useContext, useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { FLAT, SLIDE } from "../../lib/motion.js";
import { useLinkComponent, type LinkComponent } from "../../lib/link.js";
type Rect = { y: number; h: number; x: number; w: number };
type Bar = { move: (el: HTMLElement) => void; hide: () => void };
const GroupContext = createContext<Bar | null>(null);
export type ItemProps = {
/** What leads the row: an icon, an `Avatar`, a thumbnail `<img>`. */
media?: ReactNode;
/**
* How the media is framed. `icon` sits on a small hairline tile; `thumb`
* is cropped to a rounded square; `bare` is left alone (an Avatar has its own shape).
*/
mediaVariant?: "icon" | "thumb" | "bare";
title: ReactNode;
description?: ReactNode;
/** On the right: a badge, a meta line, buttons. Stays clickable when the row is a link. */
actions?: ReactNode;
/** An in-app destination, routed through your link component. */
to?: string;
/** A plain link. */
href?: string;
external?: boolean;
/** The row is an action rather than a destination. */
onClick?: () => void;
/** Override the routed link component for this row only. */
link?: LinkComponent;
size?: "sm" | "md";
className?: string;
};
const MEDIA: Record<NonNullable<ItemProps["mediaVariant"]>, Record<"sm" | "md", string>> = {
icon: {
sm: "h-7 w-7 rounded-[8px] border-[0.5px] border-gray-alpha-400 bg-background-100 text-muted",
md: "h-9 w-9 rounded-[10px] border-[0.5px] border-gray-alpha-400 bg-background-100 text-muted",
},
thumb: {
sm: "h-8 w-8 overflow-hidden rounded-[8px] bg-gray-alpha-100 [&>img]:h-full [&>img]:w-full [&>img]:object-cover",
md: "h-10 w-10 overflow-hidden rounded-[10px] bg-gray-alpha-100 [&>img]:h-full [&>img]:w-full [&>img]:object-cover",
},
bare: { sm: "", md: "" },
};
/**
* A row: something to recognise it by, a title, a line under it, and what you
* can do with it on the right.
*
* As a link or a button the whole row is the target, but the row itself is
* not the `<a>` — trailing buttons inside a link are invalid and swallow each
* other's clicks. The title is the link, and its hit area is stretched over
* the row underneath the actions, so pointing anywhere opens it and the
* actions still take their own presses.
*
* Inside an `ItemGroup` a row does not paint its own hover. The group owns one
* highlight and slides it to whichever row you point at or tab to — see
* `ItemGroup`. A lone interactive row, with no group to own a highlight,
* falls back to a quiet fill of its own.
*/
export function Item({
media,
mediaVariant = "icon",
title,
description,
actions,
to,
href,
external,
onClick,
link,
size = "md",
className = "",
}: ItemProps) {
const bar = useContext(GroupContext);
const Link = useLinkComponent(link);
const interactive = !!(to || href || onClick);
// The stretched target: an empty box over the whole row, under the actions.
const stretch = "after:absolute after:inset-0 after:rounded-[inherit] after:content-['']";
const titleCls = `text-left text-[14px] font-medium leading-[1.35] text-gray-1000 ${interactive ? stretch : ""}`;
const titleNode = to ? (
<Link href={to} className={titleCls}>
{title}
</Link>
) : href ? (
<a href={href} target={external ? "_blank" : undefined} rel={external ? "noopener" : undefined} className={titleCls}>
{title}
</a>
) : onClick ? (
<button type="button" onClick={onClick} className={titleCls}>
{title}
</button>
) : (
<span className={titleCls}>{title}</span>
);
const pad = size === "sm" ? "gap-2.5 px-2.5 py-2" : "gap-3 px-3 py-2.5";
const solo = interactive && !bar ? "transition-colors duration-150 ease-out hover:bg-gray-alpha-100" : "";
return (
<div
className={`relative flex items-center rounded-[12px] ${pad} ${solo} ${className}`}
onMouseEnter={bar && interactive ? (e) => bar.move(e.currentTarget) : undefined}
onFocus={bar && interactive ? (e) => bar.move(e.currentTarget) : undefined}
onBlur={bar && interactive ? bar.hide : undefined}
>
{media && <span className={`flex shrink-0 items-center justify-center ${MEDIA[mediaVariant][size]}`}>{media}</span>}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{titleNode}
{description && <span className="truncate text-[13px] leading-[1.4] text-muted">{description}</span>}
</div>
{actions && <div className="relative z-10 flex shrink-0 items-center gap-1.5 text-[12px] text-faint">{actions}</div>}
</div>
);
}
export type ItemGroupProps = {
/** `Item`s, and anything else — a heading, a separator — between them. */
children: ReactNode;
/** The group's accessible name, when it is a list a reader would want named. */
label?: string;
/**
* The highlight's paint. Default `bg-gray-100`, which reads on the page. On a
* grey or glass surface use `bg-highlight`.
*/
barClassName?: string;
/** Give it a negative margin (`-mx-3`) so titles align with your column and the highlight bleeds past it. */
className?: string;
};
/**
* A list of rows that share one sliding highlight.
*
* One bar, moved, rather than a background per row switched on and off. Rows
* lit one at a time flicker as the cursor crosses the gaps and forget where
* you came from; one shared element re-targeting its position reads as a
* single thing following your hand, and the keyboard drives it too, because
* focus moves it exactly as hover does. It is the same mechanism as
* `HighlightList`, kept here so rows with media and trailing actions get it
* without being rebuilt as a flat list.
*/
export function ItemGroup({ children, label, barClassName = "bg-gray-100", className = "" }: ItemGroupProps) {
const reduce = useReducedMotion();
const [rect, setRect] = useState<Rect | null>(null);
const [on, setOn] = useState(false);
const move = useCallback((el: HTMLElement) => {
setRect({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
setOn(true);
}, []);
const hide = useCallback(() => setOn(false), []);
return (
<GroupContext.Provider value={{ move, hide }}>
<LazyMotion features={domAnimation} strict>
<div role="list" aria-label={label} className={`relative isolate flex flex-col ${className}`} onMouseLeave={hide}>
<m.div
aria-hidden
className={`pointer-events-none absolute left-0 top-0 -z-10 rounded-[12px] ${barClassName}`}
initial={false}
animate={rect ? { x: rect.x, y: rect.y, width: rect.w, height: rect.h, opacity: on ? 1 : 0 } : { opacity: 0 }}
transition={reduce ? FLAT : { ...SLIDE, opacity: { duration: 0.16, ease: "easeOut" } }}
/>
{Children.toArray(children).map((c, i) => (
<div role="listitem" key={isValidElement(c) && c.key != null ? c.key : i}>
{c}
</div>
))}
</div>
</LazyMotion>
</GroupContext.Provider>
);
}
"use client";
import { useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { useLinkComponent, type LinkComponent } from "../lib/link.js";
type Rect = { x: number; y: number; w: number; h: number };
export type HighlightItem = {
key: string;
/** An in-app destination — routed through your link component, so it does not reload. */
to?: string;
/** Anything off-site or outside the router: files, mailto, another origin. */
href?: string;
external?: boolean;
/** Renders a `<button>` instead of a link — for a list of actions rather than destinations. */
onClick?: () => void;
/** Marks this row as the chosen one. Style it yourself through `children`. */
active?: boolean;
/** Accessible name, for a row whose content is an icon rather than words. */
label?: string;
children: ReactNode;
};
export type HighlightListProps = {
items: HighlightItem[];
/** On the list. Give it `flex flex-col` for rows, `flex` for a horizontal strip. */
className?: string;
/** On every item. The items carry the padding — see below. */
itemClassName?: string;
/** Radius of the bar. Match it to the item's own rounding. */
radius?: number;
/**
* The bar's paint. Default `bg-gray-100`, which reads on the page background.
* On a grey or glass surface that step disappears — use `bg-highlight`, the
* elevated surface the dock's own highlight is drawn on.
*/
barClassName?: string;
/** Override the routed link component for this list only. */
link?: LinkComponent;
};
/**
* A list where a single rounded bar slides to whichever item you are pointing at.
*
* The whole idea is that there is **one** bar. A per-item background would flicker
* on and off as the cursor crosses a gap, and says nothing about where you came
* from. One shared element re-targeting its own position reads as a physical
* slide, and carries the relationship between the item you left and the one you
* arrived at.
*
* Two things follow from that, and they are the two things people get wrong:
*
* 1. **The items carry the padding**, not the list. The bar is measured from each
* item's box, so padding on the item is what makes the bar the same size
* everywhere and what gives it room to sit behind the text.
* 2. **Give the list a negative horizontal margin** (`-mx-3` against `px-3` items).
* That keeps the text aligned to your column while the bar bleeds slightly past
* it — so the bar looks like it is behind the text rather than boxing it in.
*
* It works horizontally too: the bar tracks x, y, width and height, so a flex-row
* list gets a bar that slides sideways and resizes between items of different
* widths. `Dock` uses the same mechanism.
*
* Focus moves the bar as well as hover, so the keyboard gets this for free.
*/
export function HighlightList({
items,
className = "",
itemClassName = "",
radius = 10,
barClassName = "bg-gray-100",
link,
}: HighlightListProps) {
const [rect, setRect] = useState<Rect | null>(null);
const [active, setActive] = useState(false);
const reduce = useReducedMotion();
const Link = useLinkComponent(link);
const move = (el: HTMLElement) => {
setRect({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight });
setActive(true);
};
const handlers = {
onMouseEnter: (e: { currentTarget: HTMLElement }) => move(e.currentTarget),
onFocus: (e: { currentTarget: HTMLElement }) => move(e.currentTarget),
onBlur: () => setActive(false),
};
return (
<LazyMotion features={domAnimation} strict>
<div className={`relative isolate ${className}`} onMouseLeave={() => setActive(false)}>
<m.div
aria-hidden
className={`pointer-events-none absolute left-0 top-0 -z-10 ${barClassName}`}
style={{ borderRadius: radius }}
// `initial={false}` matters: without it the bar animates in from nothing
// on mount, in a corner, before anyone has pointed at anything.
initial={false}
animate={
rect
? { x: rect.x, y: rect.y, width: rect.w, height: rect.h, opacity: active ? 1 : 0 }
: { opacity: 0 }
}
transition={
reduce
? { duration: 0.15 }
: {
type: "spring",
bounce: 0.2,
duration: 0.4,
// Position springs; opacity does not. A spring on opacity would
// let the bar overshoot past 1 and clip, which just looks broken.
opacity: { duration: 0.16, ease: "easeOut" },
}
}
/>
{items.map((it) =>
it.onClick ? (
<button
key={it.key}
type="button"
onClick={it.onClick}
aria-label={it.label}
aria-pressed={it.active}
className={itemClassName}
{...handlers}
>
{it.children}
</button>
) : it.to ? (
<Link
key={it.key}
href={it.to}
aria-label={it.label}
// A routed row marked active is the page you are on.
aria-current={it.active ? "page" : undefined}
className={itemClassName}
{...handlers}
>
{it.children}
</Link>
) : it.href ? (
<a
key={it.key}
href={it.href}
target={it.external ? "_blank" : undefined}
rel={it.external ? "noopener" : undefined}
className={itemClassName}
{...handlers}
>
{it.children}
</a>
) : (
<div key={it.key} className={itemClassName} onMouseEnter={(e) => move(e.currentTarget)}>
{it.children}
</div>
)
)}
</div>
</LazyMotion>
);
}