ADR-028: Responsive visibility props
- Status: Accepted
- Author: Willem Paul
- Deciders: Abyss Web Team
- Date: 08/19/2026
Context
Consuming teams regularly need to show or hide a component at a given breakpoint. A common case is varying a component's props by screen size, such as rendering a small Button for narrow screens and a large one for wide screens, then hiding whichever does not apply. Today, there is no first-class way to express that. Teams either hand-write a media query through the css prop:
<Button css={{ '@screen >= $md': { display: 'none' } }}>Menu</Button>or wrap the component in their own styled() component, which accepts the same @screen syntax:
const MobileMenuButton = styled(Button, { '@screen >= $md': { display: 'none' },});Neither form is broken, and both are pure CSS. The cost is ergonomic: the breakpoint and the hiding mechanism are restated at every call site, teams have to know the @screen syntax and pick the operator that gives them the bound they meant, and the boundary behavior is theirs to get right. The styled() form also trades what should be one prop for an extra component in the consumer's tree and it is awkward when the same component needs to be hidden at different breakpoints in different places.
Two proposed props, hiddenFrom and visibleFrom, cover nearly all of these cases declaratively. Making them work across the library raises three separate questions:
- Where are the props intercepted?
createStyledpasses a customshouldForwardPropto Emotion, which replaces Emotion's default@emotion/is-prop-validfilter. Any prop that is neither a variant nor a reserved key is forwarded verbatim, so a naive implementation renders the prop as a lowercased attribute on the DOM element. - Which element receives the rule? Every component distributes props to its internal elements through the
abyssProps()callback, and only about a third of the files that call it tag an element with a-rootclass. - How is the breakpoint resolved and are the bounds inclusive? Breakpoints are theme values that
createThemeallows consumers to override or extend and the two props have to agree about the boundary width so they can be combined.
This ADR covers Abyss Web only.
Decision
-
Both props are declared on
Abyss.AbyssProps. Every component whose props derive fromAbyss.BasePropsinherits them and no component-level code changes are required. -
They are handled centrally in
useAbyssProps, which already receives them as part of the base Abyss props that every component forwards to it. -
They compile to CSS media queries emitted through the existing
cssprop pipeline rather than through a JavaScript viewport listener:Prop Emitted rule hiddenFrom="md"@media (min-width: 744px) { display: none }visibleFrom="md"@media (max-width: 743.98px) { display: none }hiddenFromhides the component from its breakpoint upwards, inclusive.visibleFromhides it below its breakpoint, exclusive. The exclusive upper bound matters:sm(360px) andmd(744px) are both common real device widths, and an inclusivemax-width: 744pxwould hide a component withvisibleFrom="md"on exactly the viewport where it is supposed to appear. It also means the two props can be combined —hiddenFrom="lg" visibleFrom="sm"is visible in the range[360px, 1248px)with no width at which both rules match. -
The exclusive bound is computed arithmetically, not with
calc().calc()inside a media feature value is Media Queries Level 4 and unsupported before Safari 16.4, where the entire query is dropped and the rule silently never applies. Since breakpoints are numbers, subtracting in JavaScript avoids the risk outright;calc()is reserved for the rare breakpoint defined as a string with units, which cannot be offset arithmetically. A Chromium-only component test would not have caught this, which is why it is settled in the implementation rather than left to tests. -
Breakpoints are read from the active Emotion theme, falling back to the default breakpoints when there is no
ThemeProvider, so a theme created with custom breakpoints resolves against its own values. No per-breakpoint fallback is needed:createThemeConfigdeep-mergesthemeDefaultsfirst, so every theme built throughcreateThemealways carries a full set. -
Both props are added to
RESERVED_PROP_KEYS.shouldForwardPropincreateStyledalready consults that list, so this is the single choke point that keeps the props off the DOM for every styled component in the library. -
The rule is applied only to declared component surfaces. A slot registry marks a real, independently rendered DOM surface with
componentSurface: 'local'orcomponentSurface: 'portal', and the conventionalrootkey is treated aslocalunless its entry says otherwise.useSlotspassessuppressResponsiveStylestouseAbyssPropsfor every other key, so the rule never lands on a component's internal slots. A component that owns no surface itself forwards the props to the one that does throughslot.relay.The gating lives in
useSlots, which means it only applies to components that have migrated to the slot registry — 253 files today. The 39 that still calluseAbyssPropsdirectly (mostlyDataGridv1 andTablev1, plus 12 current components including all fourLayout.*components) keep the original fan-out, because nothing passessuppressResponsiveStyleson their behalf. The difference is observable:hiddenFromon aButtonemits the rule on one element, while on aScrollAreait emits on both the root and the viewport. Harmless in practice, since the extra elements sit inside a hidden subtree, but it means the surface model describes the migrated path rather than the whole library. -
Components authored with
styled()get the props too.createStyledcomposes a style function that resolves the same queries and emits the rule, so both the fullstyled('div', …)declaration and the<styled.div>shorthand support them. A styled component has no internal slots, so there is no surface to select — the single element it renders is the surface. The props are consumed there rather than forwarded onward: for a component target such asstyled(Button, …)the generated class still reaches the wrapped component throughclassName, whichuseAbyssPropsspreads onto its elements, whereas forwarding would risk leaking the props as DOM attributes through targets that spread unknown props. -
The boundary math has a single source of truth.
getResponsiveVisibilityQueriesresolves both props into raw media-feature strings, used both by the CSS ruleuseAbyssPropsemits and byuseResponsiveVisibility, its JavaScript counterpart. Were the two to compute the bound separately, a component could read as hidden to CSS and visible to JS at the same pixel width. -
Components with imperative open state unmount instead of hiding. For a presentational component the rule is purely visual. For an overlay it cannot be: a modal that is merely
display: nonestill holds the scroll lock, the focus trap, and the escape-key handler.ModalBasetherefore derives its open state fromuseResponsiveVisibility— crossing into a hidden range closes the modal for real, calls the consumer'sonClose, and unmounts the content rather than hiding it. A guard on the previous visible-open state stops a modal opened directly into a hidden range from firing anonCloseit never opened for. -
The prop type is derived from the theme, not hardcoded.
Abyss.Breakpointiskeyof Util.RemoveIndex<Breakpoints>, so it lists the theme's named breakpoints and stays in step with them. A consumer who adds a breakpoint throughcreateThemeaugments theBreakpointsinterface to have the name accepted:declare module '@uhg-abyss/web/tools/types/theme' {interface Breakpoints {xl: number;}}The props stay unprefixed (
hiddenFrom="md", not"$md"), unlike$-prefixed token values, because they are lookup keys rather than CSS values. -
No
!important. Thecssprop pipeline wraps its output in an&&selector, which already takes precedence over the static, variant, and compound-variant styles serialized into the same class, so the rule wins without blocking consumer overrides.
Alternatives considered
Keeping the props off the DOM
-
Restore Emotion's
is-prop-validfilter insideshouldForwardProp. This would strip every non-standard attribute rather than the two we are adding, fixing a whole class of latent leaks. It is also a much wider blast radius: the sameshouldForwardPropis used when the styled target is a React component rather than a tag, where custom props are forwarded deliberately. Worth doing, but as its own change. -
Destructure the props in each component before the rest-spread. No shared machinery, but it is 200+ edits, and any new component that forgets the destructure reintroduces the attribute silently.
-
Have
useAbyssPropsreturn a sanitized rest object for components to spread instead of their own rest variable. Cleaner in principle, but components pass their rest props through hooks such asuseButtonanduseLinkin varying orders, so adopting it means rewriting each component's prop plumbing.
Choosing which elements get the rule
-
Root element only, detected by a
-rootclass suffix. Emits the smallest possible CSS and is the more obviously correct model. The problem is coverage: roughly a third of the files callingabyssPropstag an element with a-rootclass, so for the rest, a documented prop would silently do nothing. -
Root element only, with an explicit flag passed through the
abyssProps()options argument. Precise, but it requires auditing every component and a component that omits the flag fails silently in the same way.
Both root-only alternatives were tried, and the second shipped briefly (see Revision history). Scoping to the declared root key alone reopened exactly the gap the fan-out existed to close: ModalBase declares no root, so its portalled content stopped being hidden at all.
The component-surface model is the third position, and the one adopted. It keeps what the fan-out got right: portalled content is still reached, because a portal surface is declared as such and carries its own rule instead of relying on a hidden ancestor. ModalDialog renders into a separate theme provider root appended to <body>, so an ancestor rule would never have covered it. And it drops what the fan-out got wrong: repeating the declaration across internal slots that are already inside a hidden subtree. Unlike -root detection, it is explicit, so it does not rest on a naming convention; unlike the per-call-site flag it is declared once per registry.
Its cost is that a surface must be declared to receive the rule. 87 of 245 slot registries currently declare neither a root key nor an explicit surface. Most are internal sub-components that correctly receive nothing, and others relay the props to the component that owns the surface (e.g., DateInput to PickerInput), but a top-level component that did neither would silently ignore both props.
Resolving breakpoints
-
Reuse the
@screen >= $mdshorthand and letrefactorScreenKeyresolve it. Idiomatic and theme-aware for free. Rejected becauserefactorScreenKeymaps strict<to an inclusivemax-width, which produces exactly the boundary overlap described above. Making<exclusive there would be the better long-term fix, but it changes the behavior of a public styling API that consumers already use. -
Read
themeDefaultsdirectly. Rejected because it ignores breakpoints customized throughcreateTheme.
Typing the breakpoint values
-
Keep the hardcoded
'xs' | 'sm' | 'md' | 'lg'union. What shipped first. Rejected because it silently drifts from the theme's actual breakpoints and gives a consumer with custom breakpoints no way to name them at all. -
Theme-derived names only, extended by module augmentation.
keyof Util.RemoveIndex<Breakpoints>, with consumers writing adeclare moduleblock to add their own. Briefly adopted. It gives the strongest compile-time guarantee — a typo is an error — but was rejected on reflection for two reasons. It makes the consumer declare the same breakpoint twice, once increateThemeand again in an augmentation, and the type rejects a name that works perfectly well at runtime if they skip the second step. And it would be the only theme-derived type in the library to require that:declare moduleappears nowhere in the consumer documentation, whileAbyss.Colorand the rest accept any string, so custom tokens need no ceremony. -
Accept any string, paired with a development warning — adopted. Consistent with the other theme-derived types, and breakpoints added through
createThemework with nothing extra. The cost is that a typo compiles; the warning is what keeps that from being a silent failure.
A reviewer also raised accepting raw widths, on the grounds that some cases are "more fine tuned than just screen size breakpoints (i.e. once things don't fit in a container in one line)". The permissive type means such a value now compiles, but it is deliberately not supported at runtime, and warns. Two reasons. These props resolve to @media (min-width: …), a viewport query, so a raw value such as hiddenFrom="500px" would fire on viewport width no matter how wide the container actually is — the container-fit case needs container queries (@container), which is a separate feature. And accepting raw widths would reintroduce exactly the hardcoded, call-site-duplicated values the Context says these props exist to remove. For a genuine one-off width the existing escape hatch is better suited: the css prop and styled() both take arbitrary @media/@screen conditions, as shown in the Context.
Hiding mechanism
-
The
useMediaQueryhook plus conditional rendering. Removes the element from the DOM entirely, so its subtree costs nothing. Rejected on the strength of prior art: PR #5468opens in a new window removed most internal uses ofuseMediaQueryfromabyss-webin favor of@screenselectors and theMediaQuerycomponent, keeping the hook only where the breakpoint drives behavior rather than presentation, and updated the docs to steer consumers the same way. Building a new presentational API on the pattern that work moved away from would undo that. The reasons hold here too: a JavaScript viewport check has no correct server-rendered output, so it costs a hydration mismatch and a flash of the wrong layout, and Abyss supports SSR. -
Tell teams to use the existing
MediaQuerycomponent and add nothing. This is the closest relative of the proposed props and deserves the most weight.MediaQueryis already CSS-based, not JavaScript-based: it renders a wrapper element that isdisplay: noneby default and switches todisplay: contentsinside the matching query, and it resolves breakpoints from the theme with a hardcoded fallback. So it is SSR-safe and, like these props, hides rather than unmounts.The difference is that it is a wrapper. Hiding one component in place means introducing an element into the consumer's tree, which is intrusive in layout containers that style or count their direct children, and it reads poorly when the thing being varied is a single prop on a single component.
MediaQueryremains the right tool for a whole section; these props cover the single-component case it handles awkwardly.MediaQueryalso sets its exclusive bound asmax-width: (breakpoint - 1)px. This ADR usesbreakpoint - 0.02pxinstead, which is exact at fractional viewport widths produced by browser zoom and display scaling, where a whole-pixel offset leaves a gap. The inconsistency between the two is worth resolving in one direction or the other.
Consequences
Pros
- Consumers express responsive visibility declaratively without writing media queries or duplicating breakpoint values in product code.
- Available on every Abyss component immediately with no per-component work now and none for new components later.
- The visual hide is purely CSS, so it is correct under SSR with no hydration mismatch and no layout flash. Where behavior must follow the breakpoint as well,
useResponsiveVisibilitydefers its first value to an effect, so the server-rendered markup still matches on first client paint. display: noneremoves the element from the accessibility tree, so hidden content is not announced by screen readers.- Custom theme breakpoints work without any additional wiring.
Cons
- The element is still rendered, mounted, and shipped in the markup. Its effects run, its data is fetched, and its subtree costs rendering work even when hidden. Conditional rendering remains the right tool when the cost of the subtree is the point.
- A component receives the props only if its registry declares a surface, or relays them to one that does. This is explicit rather than inferred, but it is one more thing to remember when adding a top-level component, and forgetting it fails silently.
- Hiding a direct child of
Layout.Stack/Layout.Groupdoes not fully collapse the gap around it. TheStack/Groupwraps every child in its own element carrying half ofspaceas margin and only the child is hidden, but the wrapper and its margins stay in flow. Other layout containers that space children by wrapping them rather than withgapwill all behave this way. - Hiding relies on
display. A component whose root setsdisplayunder a stronger selector than thecsspipeline's&&would not be hidden. None do today, and any such case is a bug in that component's styles. hiddenFromandvisibleFromare reserved names in the library. A styled component cannot forward a prop of either name through to its target, because the styled layer consumes them to emit the rule itself.- For overlay components the props are not purely presentational. Crossing the breakpoint invokes
onClose, so consumer state changes as a side effect of a viewport resize. That is required for the overlay to genuinely stop trapping focus, but it meanshiddenFromon aModalDialogis not a pure styling prop, and a consumer'sonClosehas to tolerate being called without any user interaction. - Two more props on every component's documented API surface.
Testing requirements
Because both props are inherited by every component rather than implemented per component, coverage belongs to the mechanism, not to each component that happens to use it. The mechanism has three entry points, and all three need covering: useSlots, which is how an Abyss component reaches it today and where surface gating happens; useAbyssProps, the hook underneath, still called directly by components that predate the slot registry; and createStyled, for components authored with styled(). The suite in ResponsiveVisibility.spec.tsx, ResponsiveVisibilityCom.tsx, and ResponsiveVisibility.test.tsx is the reference implementation of the pattern below.
Any change to how these props resolve — and any component that needs its own coverage because it renders unusually — must include all four checks:
| Check | Where | Must assert |
|---|---|---|
| 1. Breakpoint boundaries | Playwright CT | Computed display one pixel below the breakpoint, at exactly the breakpoint, and one pixel above — for each prop. Assert on computed style, not screenshots. |
| 2. DOM-prop filtering | Jest snapshot + Playwright CT | Neither prop appears in the rendered markup, and no element carries either prop name lowercased as an attribute. |
| 3. SSR and hydration | Playwright SSR | The spec runs in both modes: pass an ssrPath to mountWrapper pointing at the story's sandbox route. The ssrTest fixture then asserts no hydration errors automatically after each test. |
| 4. A non-standard render path | Playwright CT | At least one component that does not render a plain subtree — a portal, or a layout container that wraps its children. Assert against that component's own mechanism: presentational portals (Tooltip, Popover content) stay mounted and go display: none, while overlays that unmount must be asserted absent from the DOM. |
| 5. Behavior that follows the breakpoint | Jest | For any component whose open or mounted state depends on these props, assert the transition itself: that onClose fires once when an open component crosses into a hidden range, that it does not fire when the component is opened directly into one, and that the component stays closed when the viewport comes back. |
Why each is required rather than nice to have:
- Boundaries. The exclusive upper bound on
visibleFromis the whole reason a swapped pair does not both vanish at exactly the breakpoint, and360pxand744pxare real device widths. jsdom does not evaluate media queries, so this is only observable in a browser — a unit test cannot substitute. - DOM-prop filtering.
createStyledreplaces Emotion'sis-prop-validfilter, so a prop that is not a variant or a reserved key leaks to the DOM as a lowercased attribute. This is the defect the feature shipped with beforeRESERVED_PROP_KEYSwas updated, and nothing else in the pipeline would catch its return. - SSR and hydration. Being correct under SSR with no hydration mismatch is a stated advantage of the CSS approach over a JavaScript viewport check. An untested claim is not an advantage.
- Non-standard render path. Portalled content sits outside any hidden ancestor, so it exercises the component-surface decision directly — it is hidden only because its surface is declared and carries its own rule. Wrapping layout containers expose the margin caveat in Cons #3. A component that renders a plain subtree reveals neither.
- Breakpoint-driven behavior. Overlays reinterpret the props as open state, which is the one place a viewport change mutates consumer state. The failure modes are quiet — an
onClosethat fires on mount, or one that fires repeatedly while resizing — and neither is visible in a snapshot or a computed style.
Two things a test in this area must not rely on:
ReactDOM.createPortalis globally mocked in Jest, so portals render inline. A Jest snapshot of a portalled component does not exercise the portal path at all — check 4 has to run in Playwright.- The default screenshot tolerance is
maxDiffPixelRatio: 0.1. A small component that fails to hide can change fewer pixels than that and still pass, so screenshots supplement the computed-style assertions rather than replacing them.
Future considerations
- Align the exclusive bound with
MediaQuery, which uses a whole-pixel offset. ReimplementingMediaQueryon top of these props would settle it and remove the duplicated breakpoint resolution and hardcoded fallback it carries today. - Extend
refactorScreenKeyso strict<and>emit exclusive bounds, then reduce this implementation to the@screenshorthand. That is a behavior change to a public API and needs its own ADR and visual regression pass. - Container queries would cover the "no longer fits in its container" case that raw width values cannot (see "Typing the breakpoint values"). That is a separate feature, not a widening of these props.
- If teams ask for hiding within a single band rather than from a breakpoint outward, consider a range form instead of stacking
hiddenFromandvisibleFrom. - Migrate the 39 components that still call
useAbyssPropsdirectly ontouseSlots, so surface gating applies library-wide rather than only to migrated components. Until then those components fan the rule out to every key they tag. - Audit the 87 slot registries that declare neither a
rootkey nor a surface, and confirm each is either an internal sub-component or relays to a surface owner. A lint rule overdefineSlotswould make the omission loud rather than silent.
References
- ADR-013: Stitches replacement — the
styled()wrapper andshouldForwardPropbehavior this depends on - ADR-014: Styling nested Abyss components — the
abyssProps()class distribution model - PR #5468opens in a new window:
refactor(web): Replace internal uses of useMediaQuery hook [US10078753]— established the preference for CSS-based responsive behavior over JavaScript viewport checks - Mantine's equivalent propsopens in a new window
Revision history
08/19/2026: Initial draft created
09/28/2026: Scoped the fan-out to only the component's declared root slot instead of every class abyssProps()/useSlots() tags, now that the slot-registry migration gives (most) components a well-defined root key — see "Choosing which elements get the rule" above. This reopens the portal gap that alternative was rejected for: ModalBase declares no root slot, so hiddenFrom/visibleFrom no longer reach its portalled content. Accepted as a known regression for now (see the fixmed Playwright test in ResponsiveVisibility.spec.tsx); follow-up is to give portal-rendering components an explicit root slot or another way to opt back into the fan-out.
10/02/2026:
-
Status changed from "Proposed" to "Accepted".
-
Replaced the
root-only scoping from the previous revision with an explicit component-surface model (componentSurface: 'local' | 'portal', withrootimplicitlylocal), which resolves the portal regression that revision accepted —ModalBasenow declares its overlay and content container as portal surfaces, and thefixmed Playwright test is gone. Also records two additions that came out of making overlays work:getResponsiveVisibilityQueriesas the shared boundary math behind both the CSS rule and the newuseResponsiveVisibilityhook, and the decision that components with imperative open state unmount and callonCloserather than hide. -
Typed both props as
Abyss.Breakpoint, derived from theBreakpointsinterface rather than the hardcoded'xs' | 'sm' | 'md' | 'lg'union, then widened it withUtil.Stringso breakpoints added throughcreateThemeare accepted without a module augmentation — matching how every other theme-derived type behaves. Added a development warning for a name the active theme does not define, which is what makes the permissive type safe to offer. An earlier draft of this revision rejected accepting arbitrary strings; what changed is in "Typing the breakpoint values" — custom breakpoint names resolve correctly at runtime and only the type was rejecting them, while raw CSS widths remain unsupported for the original reasons. -
Extended support to components authored with
styled()— both the full declaration and the<styled.div>shorthand — by composing a responsive-visibility style function intocreateStyled. Previously the props type-checked on a styled component and then silently did nothing, including whenstyled()wrapped an Abyss component that does support them, making it a forwarding gap rather than only a missing feature. Also movedgetResponsiveVisibilityQueriesfromhooks/useAbyssProps/totools/responsiveVisibility/, since the styling core now depends on it andtools/importing fromhooks/would invert the layering.