Dim Group
Point at one item of a group and the others dim, so the one you are on is the one you read.
$npx shadcn@latest add https://framebrick.co/r/dim-group.jsonFrom the Lookis template, see it in context.
import { DimGroup } from "@/components/ui/dim-group";
const links = ["Home", "Portfolio", "Services", "About", "Contact"];
export default function DimGroupDemo() {
return (
<DimGroup as="ul" className="flex flex-col gap-1 text-lg font-medium">
{links.map((link) => (
<li key={link}>
<a href="#" className="rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
{link}
</a>
</li>
))}
</DimGroup>
);
}Examples
Service rows
Rows with a price and a round arrow: the others dim while the one you are on fills its arrow.
import { DimGroup } from "@/components/ui/dim-group";
const services = [
{ title: "Portraits", price: "$900 – $3,000" },
{ title: "Weddings", price: "$2,500 – $8,000" },
{ title: "Editorial", price: "$1,200 – $4,500" },
];
export default function DimGroupRowsDemo() {
return (
<DimGroup as="ul" className="w-full max-w-sm divide-y border-y">
{services.map((service) => (
<li key={service.title} className="group/row flex items-center gap-3 py-3">
<a href="#" className="flex-1 truncate rounded-sm text-sm font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
{service.title}
</a>
<span className="rounded-full border px-2.5 py-0.5 text-xs text-muted-foreground">{service.price}</span>
<span
aria-hidden
className="grid size-8 shrink-0 place-items-center rounded-full border transition-colors duration-300 group-hover/row:border-foreground group-hover/row:bg-foreground group-hover/row:text-background"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="size-4 transition-transform duration-300 group-hover/row:rotate-45">
<path d="M4.5 11.5l7-7M5.5 4.5h6v6" />
</svg>
</span>
</li>
))}
</DimGroup>
);
}<DimGroup as="ul" className="divide-y border-y">
{services.map((service) => (
<li key={service.title} className="group/row flex items-center gap-3 py-3">
<a href={service.href} className="flex-1">{service.title}</a>
<span className="grid size-8 place-items-center rounded-full border group-hover/row:bg-foreground group-hover/row:text-background">
<ArrowUpRight className="size-4 group-hover/row:rotate-45" />
</span>
</li>
))}
</DimGroup>Installation
$ npx shadcn@latest add https://framebrick.co/r/dim-group.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/dim-group.tsx.import * as React from "react"; import { cn } from "@/lib/utils"; /* * Dim Group — point at one item of a group and the others dim: the Lookis service rows and footer links fade to half, the * Marai Neesu social tiles to 30%, so the one you are on is the one you read. Hover and keyboard focus both do it. * * It is plain CSS: no animation library, no JavaScript, so it works as it is on a server page. The direct children are the * items (the <li>s of a list, the links of a nav). The one under the pointer, or holding the focus ring, keeps full opacity * and the rest go to `dim`; moving to another item swaps them, and leaving the group brings every item back. The fade is * Framer's spring (bounce 0, 0.4s) as a CSS linear() curve sampled from the spring itself (tools/ui/spring-curve.mjs in the * Framebrick workspace; within 1% of it); a browser without linear() — before Chrome 113, Firefox 112 and Safari 17.2 — * gets cubic-bezier(0.22, 1, 0.36, 1). A fade is allowed with reduced motion, so it stays. * * `dim` is the opacity of the items that are not pointed at. The templates use 0.5, but opacity multiplies the text with it: * foreground text at 50% measures 3.2 to 5.2:1 on the page depending on the theme (3.7:1 in the default one, light), at 60% * 4.4:1 in the worst, at 65% 5.1:1 or better in the four sample themes, light and dark — so 0.65 is the default. Text in * muted-foreground is under 4.5:1 at any dim (2.5:1 at 65%): give secondary text its own tone, as Accordion List does. * Hover only applies where the device can hover; focus works everywhere. Needs :has(), which every current browser has * (Firefox since 121). */ const fade = "[--dim-ease:cubic-bezier(0.22,1,0.36,1)] supports-[transition-timing-function:linear(0,1)]:[--dim-ease:linear(0,0.041,0.134,0.248,0.364,0.472,0.569,0.652,0.722,0.78,0.827,0.864,0.894,0.918,0.937,0.951,0.963,0.971,0.978,0.983,0.987,0.99,0.993,0.995,0.996,0.997,0.998,0.998,0.999,1)] [&>*]:transition-opacity [&>*]:duration-400 [&>*]:ease-(--dim-ease)"; /* The focus ring is on a child itself (a link that is the item), or on something inside one (a link in an <li>). A comma inside an arbitrary variant is not generated by Tailwind, so the two cases are two rules. */ const dimming = [ "[@media(hover:hover)]:[&:has(>:hover)>:not(:hover)]:opacity-(--dim)", "[&:has(>:focus-visible)>:not(:focus-visible)]:opacity-(--dim)", "[&:has(>*_:focus-visible)>:not(:has(:focus-visible))]:opacity-(--dim)", ].join(" "); export type DimGroupProps = React.HTMLAttributes<HTMLElement> & { /** The element drawn: a list, a nav, or a div. */ as?: "div" | "ul" | "ol" | "nav"; /** Opacity of the items that are not pointed at, 0 to 1. */ dim?: number; }; export function DimGroup({ as: Tag = "div", dim = 0.65, className, style, ...props }: DimGroupProps) { const Component = Tag as React.ElementType; return <Component {...props} style={{ ["--dim" as string]: dim, ...style }} className={cn(fade, dimming, className)} />; }
Usage
import { DimGroup } from "@/components/ui/dim-group";
<DimGroup as="ul" className="flex flex-col gap-1 text-lg">
{links.map((link) => (
<li key={link.href}>
<a href={link.href}>{link.label}</a>
</li>
))}
</DimGroup>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| as | "div" | "ul" | "ol" | "nav" | "div" | The element drawn. Its direct children are the items. |
| dim | number | 0.65 | Opacity of the items that are not pointed at, 0 to 1. The templates use 0.5; see the notes on contrast. |
| …props | HTML attributes | — | Passed to the element (className lays the items out). |
- Plain CSS: no JavaScript and no animation library, so it works on a server page. It needs :has(), which every current browser has (Firefox since 121).
- The direct children are the items. The one under the pointer, or holding the focus ring, keeps full opacity; moving to another swaps them, and leaving the group brings every item back. Hover only applies where the device can hover; keyboard focus works everywhere.
- The fade is Framer's spring (bounce 0, 0.4 s) as a CSS linear() curve, within 1% of it. A fade stays with reduced motion.
- Opacity multiplies the text with it. Foreground text at 50% (what the templates use) measures 3.2 to 5.2:1 depending on the theme, at 60% 4.4:1 in the worst, and at 65% 5.1:1 or better in the four sample themes, light and dark: that is why 0.65 is the default. Text in muted-foreground is under 4.5:1 at any dim (2.5:1 at 65%), so give secondary text its own tone.
- Lookis dims its service rows (spring, bounce 0) and its footer links (bounce 0.2) to half. Marai Neesu's social tiles fade to 30% while the hovered one shows its icon: that is a composition of its own, with group-hover classes on the items.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called DimGroup — plain CSS, no JavaScript and no animation library, so it works in a server component.
- Props: as ("div" | "ul" | "ol" | "nav", div by default), dim (number, default 0.65), className and the HTML attributes. It renders that element with the dim as the CSS variable --dim.
- The direct children are the items. On a device that can hover, when a child is hovered every other child goes to opacity var(--dim): [@media(hover:hover)]:[&:has(>:hover)>:not(:hover)]:opacity-(--dim).
The same for keyboard focus, as two rules (Tailwind does not generate a comma inside an arbitrary variant): a child that has :focus-visible itself,
[&:has(>:focus-visible)>:not(:focus-visible)]:opacity-(--dim), and something inside a child that has it, [&:has(>*_:focus-visible)>:not(:has(:focus-visible))]:opacity-(--dim).
- Each child transitions opacity over 400ms on a spring (bounce 0) written as a CSS linear() curve, with cubic-bezier(0.22, 1, 0.36, 1) as the fallback for a browser without linear(). The fade stays with reduced motion.
- Keep the opacity at 0.65 or more for text so it keeps 4.5:1 on the page; muted-foreground text cannot be dimmed and keep it.










