Filter buttons over a grid of cards: the cards that stay slide to their new places, the ones that go fade out, the ones that return fade in.
Focus Siblings
A group whose item pointed at or focused stays as it is while its siblings step back: dimmed, and a little smaller or softer if you ask. One :has() rule on the group reads which child is pointed at, so the items are the group's own children, whatever they are: cards, rows of a list, portraits. The pointer counts only on a screen that can hover, since a tap leaves :hover behind; a focus ring from the keyboard counts everywhere, and a click's focus does not. Zero JavaScript.
Live demo
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
0 B
CSS 4.9 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 105 · Firefox 121 · Safari 15.4; needs has. Elsewhere: without :has() every item stays as it is.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import FocusSiblings from '@moonarc/core/FocusSiblings';
---
<FocusSiblings as="ul" dim={0.4} scale={0.98} label="The team">
<li><a href="/team/ada/">Ada Lind, dentist</a></li>
<li><a href="/team/tomas/">Tomás Ruiz, hygienist</a></li>
<li><a href="/team/mei/">Mei Tan, orthodontist</a></li>
</FocusSiblings>Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/focus-siblings.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* FocusSiblings — a group whose item pointed at or focused stays as it is while its siblings step back: dimmed, and
* a little smaller or softer if you ask. One :has() rule on the group reads which child is pointed at; the items are
* the group's own children, whatever they are (cards, rows, list items). The pointer only counts on a screen that can
* hover; the keyboard's focus counts everywhere. Dimmed text loses contrast while it is dimmed; `grey` steps the
* others back in grey at full opacity instead, which keeps it. Zero JavaScript.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** The group's element: a list when its children are <li>. */
as?: 'div' | 'ul' | 'ol';
/** The opacity of the items stepped back, 0 to 1. */
dim?: number;
/** How far they blur, a CSS length: '0px' for none. */
blur?: string;
/** How far they shrink, 1 for not at all. */
scale?: number;
/** How far they turn grey, 0 to 1: a step back that keeps the text's contrast, where `dim` lowers it. */
grey?: number;
/** A name for the group, when it is a list a screen reader should name. */
label?: string;
}
const { as: Tag = 'div', dim = 0.4, blur = '0px', scale = 1, grey = 0, label, class: className, style, ...rest } = Astro.props;
const vars = [`--ma-fsib-dim:${dim}`, `--ma-fsib-blur:${blur}`, `--ma-fsib-scale:${scale}`, `--ma-fsib-grey:${grey}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<Tag class:list={['ma-fsib', className]} aria-label={label} style={vars} {...rest}><slot /></Tag>
<style is:global>
@layer components {
:where(.ma-fsib) { --ma-fsib-dim: 0.4; --ma-fsib-blur: 0px; --ma-fsib-scale: 1; --ma-fsib-grey: 0; }
:where(ul.ma-fsib, ol.ma-fsib) { margin: 0; padding: 0; list-style: none; }
:where(.ma-fsib > *) {
transition:
opacity var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out),
filter var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out),
scale var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out);
}
/* the pointer, where there is one that hovers: a tap on a touch screen leaves :hover behind on the item it hit */
@media (hover: hover) {
:where(.ma-fsib:has(> :hover) > :not(:hover)) { opacity: var(--ma-fsib-dim); filter: blur(var(--ma-fsib-blur)) grayscale(var(--ma-fsib-grey)); scale: var(--ma-fsib-scale); }
}
/* the keyboard: a focus ring inside an item steps the others back, a click's focus does not */
:where(.ma-fsib:has(:focus-visible) > :not(:focus-within)) { opacity: var(--ma-fsib-dim); filter: blur(var(--ma-fsib-blur)) grayscale(var(--ma-fsib-grey)); scale: var(--ma-fsib-scale); }
@media (prefers-reduced-motion: reduce) {
/* the others still step back, at once and in place */
:where(.ma-fsib > *) { transition: none; }
:where(.ma-fsib:has(> :hover) > *), :where(.ma-fsib:has(:focus-visible) > *) { scale: 1; }
}
}
</style>Props
| Prop | Type | Default | Description |
|---|---|---|---|
as | 'div' | 'ul' | 'ol' | 'div' | The group's element: a list when its children are <li>. |
dim | number | 0.4 | The opacity of the items stepped back, 0 to 1. |
blur | string | '0px' | How far they blur, a CSS length. |
scale | number | 1 | How far they shrink: 0.97 is a step back, 1 is none. |
grey | number | 0 | How far they turn grey, 0 to 1. With dim at 1 the others step back in grey and their text keeps its contrast. |
label | string | none | A name for the group, when a screen reader should hear one. |
Reduced motion
The others still dim, at once and without shrinking.
With ClientRouter
No script: it works on any page however it was reached.
Why it is built this way
- The rule is :has(> :hover) > :not(:hover) on the group, inside :where(), so a class on an item overrides it without a fight.
- The pointer rule sits under (hover: hover): on a phone a tap would leave the item it hit hovered and the rest dimmed until the next tap.
- The keyboard rule reads :focus-visible anywhere in the group, so a link inside a card counts, and a mouse click's focus does not dim the rest.
- A dimmed item stays where it is and keeps its size by default: the step back is opacity, which moves nothing around it.
- Opacity lowers the contrast of the text it dims, and axe reports it while an item is pointed at: for cards of text, grey={1} with dim={1} steps the others back in grey and keeps every line at its contrast.
Replaces: Focus Cards (Aceternity) · Hover focus (Animata) · Focus list (React Bits). See the migration table.
Related
Hover Image Links
PointerA list of project links: hovering one floats its image beside the pointer, leaning the way the pointer moves, while the other links step back.
Spotlight
PointerA soft radial light follows the cursor over a card and fades in only while hovered.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown