Learn

/

Responsive Props

Responsive Props

5 patterns

Components that accept breakpoint-aware prop objects like direction={{ xs: 'column', md: 'row' }}. You'll hit this when you want a Stack to be vertical on mobile and horizontal on desktop in one prop.

Avoid
function Features() {
  const isMobile = useMediaQuery("(max-width: 600px)");

  return (
    <Stack direction={isMobile ? "column" : "row"}>
      <FeatureCard />
      <FeatureCard />
    </Stack>
  );
}
function Features() {
  const isMobile = useMediaQuery("(max-width: 600px)");

  return (
    <Stack direction={isMobile ? "column" : "row"}>
      <FeatureCard />
      <FeatureCard />
    </Stack>
  );
}

Prefer
function Features() {
  return (
    <Stack direction={{ xs: "column", sm: "row" }}>
      <FeatureCard />
      <FeatureCard />
    </Stack>
  );
}
function Features() {
  return (
    <Stack direction={{ xs: "column", sm: "row" }}>
      <FeatureCard />
      <FeatureCard />
    </Stack>
  );
}
Why avoid

useMediaQuery defaults to false during SSR, so the server always renders the row layout. On mobile, React hydrates and immediately re-renders to column, causing a visible layout flash.

Why prefer

MUI's responsive prop objects compile to CSS media queries with no hook, no re-render, and no SSR flash. The intent is also more readable: { xs: 'column', sm: 'row' } is a data structure describing the layout at each breakpoint.

MUI: Stack responsive values
Avoid
function PageTitle({ children }: Props) {
  const isMobile = useMediaQuery("(max-width: 600px)");

  return (
    <Typography variant={isMobile ? "h5" : "h3"}>
      {children}
    </Typography>
  );
}
function PageTitle({ children }: Props) {
  const isMobile = useMediaQuery("(max-width: 600px)");

  return (
    <Typography variant={isMobile ? "h5" : "h3"}>
      {children}
    </Typography>
  );
}

Prefer
function PageTitle({ children }: Props) {
  return (
    <Typography
      variant="h3"
      sx={{
        fontSize: { xs: "1.5rem", sm: "2rem", md: "3rem" },
      }}
    >
      {children}
    </Typography>
  );
}
function PageTitle({ children }: Props) {
  return (
    <Typography
      variant="h3"
      sx={{
        fontSize: { xs: "1.5rem", sm: "2rem", md: "3rem" },
      }}
    >
      {children}
    </Typography>
  );
}
Why avoid

Changing variant between h5 and h3 changes the rendered HTML element, which affects heading hierarchy and screen reader navigation. The visual size should be separate from the semantic meaning.

Why prefer

Keep the semantic variant (h3 for heading hierarchy and accessibility) and override the visual size with responsive fontSize in sx. This gives you correct heading semantics with flexible visual sizing, and no JavaScript hook is needed.

MUI: Responsive font sizes
Avoid
interface CardProps {
  layout: "horizontal" | "vertical";
}

function Card({ layout }: CardProps) {
  return (
    <Stack
      direction={
        layout === "horizontal" ? "row" : "column"
      }
    >
      <CardImage />
      <CardContent />
    </Stack>
  );
}

// Consumer must use useMediaQuery
function Page() {
  const isMobile = useMediaQuery("(max-width: 600px)");
  return (
    <Card layout={isMobile ? "vertical" : "horizontal"} />
  );
}
interface CardProps {
  layout: "horizontal" | "vertical";
}

function Card({ layout }: CardProps) {
  return (
    <Stack
      direction={
        layout === "horizontal" ? "row" : "column"
      }
    >
      <CardImage />
      <CardContent />
    </Stack>
  );
}

// Consumer must use useMediaQuery
function Page() {
  const isMobile = useMediaQuery("(max-width: 600px)");
  return (
    <Card layout={isMobile ? "vertical" : "horizontal"} />
  );
}

Prefer
interface CardProps {
  layout:
    | "horizontal"
    | "vertical"
    | Partial<Record<Breakpoint, "horizontal" | "vertical">>;
}

function Card({ layout }: CardProps) {
  const direction =
    typeof layout === "string"
      ? layout === "horizontal" ? "row" : "column"
      : Object.fromEntries(
          Object.entries(layout).map(([bp, l]) => [
            bp,
            l === "horizontal" ? "row" : "column",
          ]),
        );

  return (
    <Stack direction={direction}>
      <CardImage />
      <CardContent />
    </Stack>
  );
}

// Consumer: zero hooks needed
function Page() {
  return (
    <Card layout={{ xs: "vertical", sm: "horizontal" }} />
  );
}
interface CardProps {
  layout:
    | "horizontal"
    | "vertical"
    | Partial<Record<Breakpoint, "horizontal" | "vertical">>;
}

function Card({ layout }: CardProps) {
  const direction =
    typeof layout === "string"
      ? layout === "horizontal" ? "row" : "column"
      : Object.fromEntries(
          Object.entries(layout).map(([bp, l]) => [
            bp,
            l === "horizontal" ? "row" : "column",
          ]),
        );

  return (
    <Stack direction={direction}>
      <CardImage />
      <CardContent />
    </Stack>
  );
}

// Consumer: zero hooks needed
function Page() {
  return (
    <Card layout={{ xs: "vertical", sm: "horizontal" }} />
  );
}
Why avoid

Forcing the consumer to use useMediaQuery means every usage site has the same SSR hydration bug risk, and every consumer writes the same boilerplate. Push responsive logic into the component where it can be handled with CSS.

Why prefer

By accepting breakpoint objects in the API, the component handles responsiveness internally via CSS. The consumer never needs useMediaQuery. This follows MUI's pattern, so if your component wraps MUI, expose the same responsive API.

MUI: Responsive values
Avoid
interface SectionProps {
  children: ReactNode;
  hideOnMobile?: boolean;
}

function Section({ children, hideOnMobile }: SectionProps) {
  const isMobile = useMediaQuery("(max-width: 600px)");

  if (hideOnMobile && isMobile) return null;
  return <Box>{children}</Box>;
}
interface SectionProps {
  children: ReactNode;
  hideOnMobile?: boolean;
}

function Section({ children, hideOnMobile }: SectionProps) {
  const isMobile = useMediaQuery("(max-width: 600px)");

  if (hideOnMobile && isMobile) return null;
  return <Box>{children}</Box>;
}

Prefer
interface SectionProps {
  children: ReactNode;
  display?: ResponsiveStyleValue<"block" | "none">;
}

function Section({
  children,
  display = "block",
}: SectionProps) {
  return <Box sx={{ display }}>{children}</Box>;
}

// Usage
<Section display={{ xs: "none", md: "block" }}>
  <Sidebar />
</Section>
interface SectionProps {
  children: ReactNode;
  display?: ResponsiveStyleValue<"block" | "none">;
}

function Section({
  children,
  display = "block",
}: SectionProps) {
  return <Box sx={{ display }}>{children}</Box>;
}

// Usage
<Section display={{ xs: "none", md: "block" }}>
  <Sidebar />
</Section>
Why avoid

hideOnMobile is a boolean that forces a single breakpoint decision. What if you want to hide on tablet too? Or show on large phones? A responsive display prop gives the consumer full breakpoint control without adding more boolean props.

Why prefer

Exposing display as a responsive prop delegates visibility to CSS. The component renders in the HTML (good for SEO), toggles via media queries (no flash), and the consumer controls exactly which breakpoints show or hide it.

MUI: Hiding elements
Avoid
interface GridProps {
  columns: number;
  mobileColumns?: number;
  tabletColumns?: number;
}

function AppGrid({
  columns,
  mobileColumns = 1,
  tabletColumns = 2,
}: GridProps) {
  const isMobile = useMediaQuery("(max-width: 600px)");
  const isTablet = useMediaQuery("(max-width: 900px)");

  const cols = isMobile
    ? mobileColumns
    : isTablet
      ? tabletColumns
      : columns;

  return (
    <Box
      sx={{
        display: "grid",
        gridTemplateColumns: `repeat(${cols}, 1fr)`,
      }}
    >
      {/* children */}
    </Box>
  );
}
interface GridProps {
  columns: number;
  mobileColumns?: number;
  tabletColumns?: number;
}

function AppGrid({
  columns,
  mobileColumns = 1,
  tabletColumns = 2,
}: GridProps) {
  const isMobile = useMediaQuery("(max-width: 600px)");
  const isTablet = useMediaQuery("(max-width: 900px)");

  const cols = isMobile
    ? mobileColumns
    : isTablet
      ? tabletColumns
      : columns;

  return (
    <Box
      sx={{
        display: "grid",
        gridTemplateColumns: `repeat(${cols}, 1fr)`,
      }}
    >
      {/* children */}
    </Box>
  );
}

Prefer
interface GridProps {
  columns: ResponsiveStyleValue<number>;
  children: ReactNode;
}

function AppGrid({ columns, children }: GridProps) {
  const gridTemplateColumns =
    typeof columns === "number"
      ? `repeat(${columns}, 1fr)`
      : Object.fromEntries(
          Object.entries(columns).map(([bp, n]) => [
            bp,
            `repeat(${n}, 1fr)`,
          ]),
        );

  return (
    <Box sx={{ display: "grid", gridTemplateColumns }}>
      {children}
    </Box>
  );
}

// Usage: clean, one prop
<AppGrid columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}>
interface GridProps {
  columns: ResponsiveStyleValue<number>;
  children: ReactNode;
}

function AppGrid({ columns, children }: GridProps) {
  const gridTemplateColumns =
    typeof columns === "number"
      ? `repeat(${columns}, 1fr)`
      : Object.fromEntries(
          Object.entries(columns).map(([bp, n]) => [
            bp,
            `repeat(${n}, 1fr)`,
          ]),
        );

  return (
    <Box sx={{ display: "grid", gridTemplateColumns }}>
      {children}
    </Box>
  );
}

// Usage: clean, one prop
<AppGrid columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}>
Why avoid

Three props (columns, mobileColumns, tabletColumns) don't scale. What about xl? What about custom breakpoints? Two useMediaQuery hooks cause double re-renders on resize and SSR hydration issues. The responsive object pattern handles all breakpoints in one prop.

Why prefer

One columns prop accepting breakpoint objects replaces three separate props and two hooks. The consumer's API is clean (columns={{ xs: 1, md: 3 }}), and the implementation compiles to pure CSS media queries.

MUI: Grid responsive values