# Focus Siblings (Moonarc)

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.

- Import: `import FocusSiblings from '@moonarc/core/FocusSiblings'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/focus-siblings.json`
- Tier A · category pointer · trigger hover, pointer
- Readout: `<FocusSiblings as="ul" dim={0.4}>…</FocusSiblings>`
- Browser support: widely (Chrome 105 · Firefox 121 · Safari 15.4); elsewhere: without :has() every item stays as it is
- Measured cost: 0 B JS (CSS 4.9 kB raw)
- Page: https://moonarc.dev/components/focus-siblings/

## 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. |

## Usage

```astro
<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>
```

## Reduced motion

The others still dim, at once and without shrinking.

## With ClientRouter

No script: it works on any page however it was reached.

## Craft

- 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)

## Source

```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>

```
