{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dim-siblings","type":"registry:ui","title":"DimSiblings","description":"Wraps a group of cards, links, or list items so hovering or keyboard-focusing one dims (or blurs) every other one — a pure CSS :has() affordance with zero pointer-tracking JavaScript","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["effects"],"registryDependencies":["https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/dim-siblings/DimSiblings.tsx","content":"\"use client\";\n\nimport { createElement, forwardRef, type CSSProperties, type HTMLAttributes, type ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport \"./dim-siblings.css\";\n\n/**\n * Props for DimSiblings.\n *\n * DimSiblings is the one component in this collection with zero JavaScript\n * behaviour: it renders a wrapper and three CSS custom properties, and a\n * pure `:has()` stylesheet does the rest (hover/focus one direct child,\n * dim or blur every other one). There is no pointer tracking to clean up\n * and nothing to gate behind SSR, because there is nothing here that only\n * runs in the browser.\n */\nexport interface DimSiblingsProps extends Omit<HTMLAttributes<HTMLElement>, \"className\"> {\n\t/** Additional CSS classes. */\n\tclassName?: string;\n\t/** Which visual property the non-active siblings lose. */\n\teffect?: \"dim\" | \"blur\" | \"both\";\n\t/** Opacity the non-active siblings settle to. A floor, not zero — full\n\t * transparency reads as \"the card vanished\", not \"the card is quiet\". */\n\topacity?: number;\n\t/** Blur radius, in px, applied only when `effect` includes blur. */\n\tblur?: number;\n\t/** Transition duration, in ms, for both the opacity and (if active) the blur. */\n\tduration?: number;\n\t/** The rendered root element — `\"ul\"`/`\"ol\"` for a list of cards whose\n\t * CSS list semantics need to survive the wrapper. */\n\tas?: keyof HTMLElementTagNameMap;\n\t/** The sibling group. Every direct child participates. */\n\tchildren: ReactNode;\n}\n\nconst DEFAULT_OPACITY = 0.4;\nconst DEFAULT_DURATION = 150;\n\nexport const DimSiblings = forwardRef<HTMLElement, DimSiblingsProps>(function DimSiblings(\n\t{\n\t\teffect = \"dim\",\n\t\topacity = DEFAULT_OPACITY,\n\t\tblur = 2,\n\t\tduration = DEFAULT_DURATION,\n\t\tas = \"div\",\n\t\tchildren,\n\t\tclassName,\n\t\tstyle,\n\t\t...restProps\n\t},\n\tref\n) {\n\t// Blur is opt-in per `effect`, not per the `blur` prop alone — a caller\n\t// who leaves `effect=\"dim\"` but tweaks `blur` should not get blur anyway.\n\t// Baking that choice into the CSS var itself (rather than a second\n\t// selector keyed off `effect`) keeps the frozen `:has()` rule in the\n\t// colocated stylesheet at exactly one declaration for hover and one for\n\t// focus, whatever `effect` resolves to.\n\tconst blurPx = effect === \"dim\" ? 0 : blur;\n\n\t// All three vars are declared unconditionally, `undefined` standing in for\n\t// \"at its default, write nothing\". Not just an omission: the key still\n\t// being present is what stops a caller-supplied `style` from setting the\n\t// same var behind the component's back, which is exactly what the Svelte\n\t// source does — a `style:` directive reserves its property name against\n\t// the spread's style string even when the directive resolves to\n\t// `undefined`. React skips an `undefined` style value the same way, so the\n\t// var stays absent from the rendered `style` either way.\n\tconst mergedStyle: CSSProperties = {\n\t\t...style,\n\t\t[\"--ft-dimsiblings-opacity\" as string]: opacity === DEFAULT_OPACITY ? undefined : opacity,\n\t\t[\"--ft-dimsiblings-blur\" as string]: `${blurPx}px`,\n\t\t[\"--ft-dimsiblings-duration\" as string]:\n\t\t\tduration === DEFAULT_DURATION ? undefined : `${duration}ms`,\n\t};\n\n\treturn createElement(\n\t\tas,\n\t\t{\n\t\t\t// Attribute order mirrors the Svelte source: `class` first (rest\n\t\t\t// props can never carry it — it is destructured out), then the\n\t\t\t// spread, then the component's own `data-effect` and style vars\n\t\t\t// LAST so they win. A forwarded `data-effect` must not slip past\n\t\t\t// and silently select a different branch of the stylesheet.\n\t\t\tclassName: cn(\"ft-dimsiblings\", className),\n\t\t\t...restProps,\n\t\t\t\"data-effect\": effect,\n\t\t\tstyle: mergedStyle,\n\t\t\t// After the spread for the same reason `bind:this` is a directive\n\t\t\t// on the Svelte side: nothing a caller forwards can drop the ref.\n\t\t\tref,\n\t\t},\n\t\tchildren\n\t);\n});\n\nDimSiblings.displayName = \"DimSiblings\";\n","type":"registry:ui","target":"@ui/fancy/components/dim-siblings/DimSiblings.tsx"},{"path":"react/src/components/dim-siblings/dim-siblings.css","content":"/* `(hover: hover)`-gated: on a touch screen nothing ever satisfies `:hover`\n   in the first place, but gating explicitly (rather than relying on that)\n   matches this collection's other hover-affordance precedent\n   (ThreadList's delete button) and keeps the rule from ever appearing to\n   \"stick\" on a touch UA that fakes a hover state after a tap.\n\n   `:not(:has(:focus-visible))` on the root: focus takes priority\n   over hover. Without it, a `:focus-visible` child and a `:hover` child\n   that are two different elements each dim the *other* rule's target —\n   the hover rule dims everything but the hovered item (including the\n   focused one) while the focus rule dims everything but the focused item\n   (including the hovered one), and the group ends up entirely dimmed.\n   Reached simply by pressing Tab while the pointer rests over the group. */\n@media (hover: hover) {\n\t/* Keyed on `data-effect` exactly like the `filter` rule below: the\n\t   opacity dip belongs to the dim-capable effects only, or\n\t   `effect=\"blur\"` would dim AND blur — behaving identically to\n\t   `effect=\"both\"` and leaving the public union with two names for one\n\t   effect. */\n\t.ft-dimsiblings:is([data-effect=\"dim\"], [data-effect=\"both\"]):not(:has(:focus-visible)):has(\n\t\t\t> :hover\n\t\t)\n\t\t> :not(:hover) {\n\t\topacity: var(--ft-dimsiblings-opacity, 0.4);\n\t}\n\t/* `filter` is declared only for the blur effects: a `blur(0px)` on the\n\t   default dim-only path would still promote every sibling to its own\n\t   compositing layer and flip text to grayscale antialiasing on each\n\t   hover — a visible weight change for no visual gain. */\n\t.ft-dimsiblings:is([data-effect=\"blur\"], [data-effect=\"both\"]):not(:has(:focus-visible)):has(\n\t\t\t> :hover\n\t\t)\n\t\t> :not(:hover) {\n\t\tfilter: blur(var(--ft-dimsiblings-blur, 2px));\n\t}\n}\n\n/* Unconditional: a keyboard user tabbing through the siblings gets the\n   same \"this one, not the others\" treatment regardless of hover\n   capability. `:focus-visible`, not `:focus-within` — a programmatic\n   `.focus()` or a mouse-click focus (neither of which shows a focus ring)\n   must not dim every sibling around it. The child selector excludes both\n   shapes of \"the active one\": `:has(:focus-visible)` catches a wrapped\n   descendant that holds focus, and `:focus-visible` directly catches the\n   focused element itself — `:has()` only matches descendants, so a direct\n   child that IS the focused element does not match the `:has()` half and\n   would otherwise also match `:not(:has(:focus-visible))`, dimming the\n   very item that has focus. This rule always wins over the hover rule\n   above (which excludes itself whenever a focus-visible descendant\n   exists), so a focused item is never dimmed by a same-group hover. */\n.ft-dimsiblings:is([data-effect=\"dim\"], [data-effect=\"both\"]):has(:focus-visible)\n\t> :not(:focus-visible):not(:has(:focus-visible)) {\n\topacity: var(--ft-dimsiblings-opacity, 0.4);\n}\n.ft-dimsiblings:is([data-effect=\"blur\"], [data-effect=\"both\"]):has(:focus-visible)\n\t> :not(:focus-visible):not(:has(:focus-visible)) {\n\tfilter: blur(var(--ft-dimsiblings-blur, 2px));\n}\n\n/* Only the transition is reduced-motion-gated — the dim/blur end states\n   above apply either way, they just snap instead of animating. */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-dimsiblings > * {\n\t\t/* Literal fallbacks: 150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) = tokens.EASINGS.inout */\n\t\ttransition:\n\t\t\topacity var(--ft-dimsiblings-duration, var(--ft-duration-fast, 150ms))\n\t\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1)),\n\t\t\tfilter var(--ft-dimsiblings-duration, var(--ft-duration-fast, 150ms))\n\t\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t}\n}\n\n/* A forced-colors palette (Windows High Contrast) replaces `currentColor`\n   content wholesale and has no reliable notion of \"dimmed\" — leaving this\n   rule active would silently drop contrast the OS is specifically trying\n   to guarantee. `prefers-contrast: more` covers every other platform's\n   \"increase contrast\" preference (macOS/iOS Increase Contrast, a Windows\n   contrast preference without a contrast *theme*), where nothing forces\n   colors but a reader has still said normal contrast isn't enough for\n   them; `prefers-reduced-transparency: reduce` for the same reasoning\n   applied to the opacity dip itself — this component's entire job is\n   removing contrast or adding translucency, so all three preferences get\n   the same full reset. Both selectors are repeated\n   verbatim (rather than a broader `> *` reset) so specificity ties the\n   hover/focus rules above exactly — `@media` alone adds no specificity,\n   so a looser reset here would silently lose the cascade and never\n   actually apply. */\n@media (forced-colors: active), (prefers-contrast: more), (prefers-reduced-transparency: reduce) {\n\t.ft-dimsiblings:is([data-effect=\"dim\"], [data-effect=\"both\"]):not(:has(:focus-visible)):has(\n\t\t\t> :hover\n\t\t)\n\t\t> :not(:hover),\n\t.ft-dimsiblings:is([data-effect=\"dim\"], [data-effect=\"both\"]):has(:focus-visible)\n\t\t> :not(:focus-visible):not(:has(:focus-visible)) {\n\t\topacity: 1;\n\t}\n\t/* Same (0,1,0) bump as the blur rules above, so this reset ties them. */\n\t.ft-dimsiblings:is([data-effect=\"blur\"], [data-effect=\"both\"]):not(:has(:focus-visible)):has(\n\t\t\t> :hover\n\t\t)\n\t\t> :not(:hover),\n\t.ft-dimsiblings:is([data-effect=\"blur\"], [data-effect=\"both\"]):has(:focus-visible)\n\t\t> :not(:focus-visible):not(:has(:focus-visible)) {\n\t\tfilter: none;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/dim-siblings/dim-siblings.css"},{"path":"react/src/components/dim-siblings/index.ts","content":"\"use client\";\n\nexport { DimSiblings } from \"./DimSiblings\";\nexport type { DimSiblingsProps } from \"./DimSiblings\";\n","type":"registry:ui","target":"@ui/fancy/components/dim-siblings/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/dim-siblings?framework=react"}}