Skip to content

Components / Pointer

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.

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

Baseline · widely available

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 moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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.

terminal
npx shadcn@latest add https://moonarc.dev/r/focus-siblings.json

The 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.astro
---
/**
 * 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

PropTypeDefaultDescription
as'div' | 'ul' | 'ol''div'The group's element: a list when its children are <li>.
dimnumber0.4The opacity of the items stepped back, 0 to 1.
blurstring'0px'How far they blur, a CSS length.
scalenumber1How far they shrink: 0.97 is a step back, 1 is none.
greynumber0How far they turn grey, 0 to 1. With dim at 1 the others step back in grey and their text keeps its contrast.
labelstringnoneA 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

Replaces: Focus Cards (Aceternity) · Hover focus (Animata) · Focus list (React Bits). See the migration table.

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.

newly · Chrome 137 · Firefox 144 · Safari 18.4click

Spotlight

Pointer

A soft radial light follows the cursor over a card and fades in only while hovered.

every browserpointerhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown