Responsive Layout

Responsive Layout lets a component respond to the current screen size at runtime, for the cases plain CSS can't handle (like rendering a different component or changing a prop).

  • useResponsiveLayout is the hook you reach for. It returns three flags, isMobile, isTablet, and isDesktop, with exactly one true at a time.
  • ResponsiveLayoutProvider is optional. It overrides the default breakpoints for the components beneath it.

For the principles behind responsiveness in the design system, including the scaling model and what adapts on its own, see Responsive Design.

import { useResponsiveLayout, ResponsiveLayoutProvider } from '@adaptavant/eds-core';

Reading the current screen size

Call useResponsiveLayout inside any client component to find out which screen size the viewport currently falls into. It returns three booleans, and exactly one is true at any time.

Return valueWhen it's true
isMobileviewport <= 767px
isTabletviewport 768px to 1023px
isDesktopviewport > 1023px (also the server-side default)

Conditional rendering

Read the flags and render accordingly. Here the label changes with the current breakpoint.

Resize the window to watch the value update in real time.

Current screen size:🖥️ Desktop
const { isMobile, isTablet, isDesktop } = useResponsiveLayout();

const label = isMobile
  ? '📱 Mobile'
  : isTablet
    ? '💻 Tablet'
    : '🖥️ Desktop';

return (
  <Box className="flex items-center gap-2">
    <Text>Current screen size:</Text>
    <Text className="font-strong">{label}</Text>
  </Box>
);

Switching component props

When the markup is same but only a prop should change, set that prop with the flags.

const { isDesktop } = useResponsiveLayout();
const size = isDesktop ? 'standard' : 'large';

return (
  <Button size={size} variant="accentPrimary">
    Continue
  </Button>
);

Customizing breakpoints with the provider

Use ResponsiveLayoutProvider when the default breakpoints don't fit. Place it once, high in your tree (typically wrapping the design system's Root) to apply your breakpoints across the whole app, or lower to scope them to a specific area.

Each value is the inclusive max-width (in px) for that bucket, so { mobile: 599, tablet: 1279 } means ≤ 599 mobile, 600 to 1279 tablet, ≥ 1280 desktop.

A real use case for this is running inside a WebView on a large tablet (wider than 1024px), which would otherwise be treated as Desktop. Setting tablet: 9999 forces every width below it to resolve as Tablet. Apply the same value in your Tailwind screens config so CSS and the hook stay in agreement.

See Responsive Design to learn more.

Breakpoint (mobile ≤ 599, tablet 600 to 1279, desktop ≥ 1280):desktop
function Badge() {
  const { isMobile, isTablet } = useResponsiveLayout();
  const label = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
  return <Text className="font-strong">{label}</Text>;
}

return (
  <ResponsiveLayoutProvider breakpoints={{ mobile: 599, tablet: 1279 }}>
    <Box className="flex items-center gap-2">
      <Text>Breakpoint (mobile ≤ 599, tablet 600 to 1279, desktop ≥ 1280):</Text>
      <Badge />
    </Box>
  </ResponsiveLayoutProvider>
);

Server-side rendering

useResponsiveLayout is safe to call in server-rendered apps. On the server there is no window.matchMedia, so the hook falls back to a stable default and returns isDesktop: true. The real viewport is resolved on the client after hydration.

// Server render and first client paint:
//   { isMobile: false, isTablet: false, isDesktop: true }
//
// After hydration, on a 375px-wide phone:
//   { isMobile: true,  isTablet: false, isDesktop: false }
const { isMobile, isDesktop } = useResponsiveLayout();

What this means in practice:

  • Don't gate critical or SEO-relevant content on the flags. Content that only renders when isMobile is true is absent from the server HTML and the first paint, then appears after hydration. That means a layout shift, and it stays invisible to crawlers.
  • Expect a brief desktop-first frame on small viewports before the client corrects it. For purely visual differences, prefer CSS media queries or Tailwind responsive utilities, which render correctly during SSR with no shift.
  • No provider is required for SSR. The default is built in. Use ResponsiveLayoutProvider only when you need to change the breakpoint values.

API Reference

ResponsiveLayoutProvider

You only need this provider when the default breakpoints don't fit your app. It overrides the breakpoints for every useResponsiveLayout call rendered beneath it.

PropDefaultDescription
children_React.ReactNode
The components that should read your custom breakpoints.
breakpoints?{ mobile: 767, tablet: 1023 }BreakpointConfig
Your breakpoint thresholds, as inclusive max-widths in pixels.

Best practices

Do

Keep critical content out of breakpoint branches

Render essential and SEO-relevant content unconditionally, and use the flags only to enhance or rearrange it. This keeps content present in the server HTML and the first paint.

const { isMobile } = useResponsiveLayout();

return (
  <Box>
    <Heading>Pricing</Heading>
    {isMobile ? <PricingAccordion /> : <PricingTable />}
  </Box>
);

Don’t

Don't gate essential content on client-only flags

On the server the flags resolve to desktop, so anything rendered only when isMobile is true is missing from the initial HTML. It pops in after hydration, causing a layout shift, and stays invisible to crawlers.

const { isMobile } = useResponsiveLayout();

// Heading is absent from SSR + first paint on mobile.
return isMobile ? <Heading>Pricing</Heading> : null;

Do

Use CSS for purely visual responsiveness

For show/hide, spacing, sizing, and layout that doesn't change the component tree, use Tailwind responsive utilities. They work during SSR with no hydration shift and no JavaScript.

<Box className="flex flex-col gap-2 md:flex-row md:gap-4">
  <Button>Save</Button>
  <Button>Cancel</Button>
</Box>

Don’t

Don't read flags to toggle styles CSS can handle

Reaching for the hook to switch class names on a purely visual change adds a JavaScript dependency and a desktop-first flash before hydration, with no benefit over a media query.

const { isMobile } = useResponsiveLayout();

return (
  <Box className={isMobile ? 'flex flex-col gap-2' : 'flex flex-row gap-4'}>
    <Button>Save</Button>
    <Button>Cancel</Button>
  </Box>
);