Box vs className vs style for vertical spacing in Material UI

Viewed 2282

When I am presented with a design to implement using Material UI there is invariably some vertical space between section headers, form labels, input fields, etc. There seem to be a few ways to achieve this:

  1. Wrap each <Typography />, <Checkbox />, etc. in a <Box paddingBottom={2} />.
  2. Create a class for each element with the spacing, e.g.
const useStyles = makeStyles(theme => ({ subHeader: { marginBottom: theme.spacing(2) } }));
...
const classes = useStyles();
...
<Typography className={classes.subHeader} />
  1. Use inline styles, e.g.
const theme = useTheme();
<Typography style={{ marginBottom: theme.spacing(2) }} />

Each of these approaches doesn't seem right to me.

The first adds a lot of extra divs to your HTML code and guarantees that adjacent conceptual elements are never adjacent; they are always nested.

<div class="MuiBox-root">
  <span class="MuiTypography-root" />
</div>
<div class="MuiBox-root">
  <span class="MuiTypography-root" />
</div>

With the second you end up creating lots of fairly meaningless classes to accommodate needing different spacing below each element for purely design/aesthetic reasons, not semantic reasons, such as a class with marginBottom: 2, and one with marginBottom: 3.

The third option seems to make sense as extracting out spacing logic into reusable code seems overkill, but inline styles are generally frowned upon, and having to call const theme = useTheme() in every component doesn't seem right.

TLDR; What is the recommended way for spacing components vertically in Material UI?

3 Answers

I would recommend using the clone prop of Box. This causes it to add the styles to its child (via React.cloneElement) rather than wrapping it with an extra element.

The example below adds bottom margin to the first Typography and left margin to the second without introducing any additional wrapper elements in the html.

import React from "react";
import Typography from "@material-ui/core/Typography";
import Box from "@material-ui/core/Box";

export default function App() {
  return (
    <>
      <Box mb={3} clone>
        <Typography variant="h5" color="primary">
          Some Text
        </Typography>
      </Box>
      <Box ml={2} clone>
        <Typography color="primary">Later Text</Typography>
      </Box>
    </>
  );
}

Edit Box clone

Unfortunately, as discussed in the comments, using the clone prop of Box can be brittle when there is overlap between the styles being set by the Box and the styles being set by the wrapped component (e.g. Typography) since then the order of import impacts which one wins (and not just the order of import in the particular file you are focusing on, but rather the order of their first import in the app).

One solution for these cases is to create your own wrapper component to imitate the functionality in Box that you want to use frequently. For instance, below is a component that can be used in place of Typography to control margin in a Box-like fashion:

import * as React from "react";
import Typography from "@material-ui/core/Typography";
import { makeStyles } from "@material-ui/core/styles";
import clsx from "clsx";

const useStyles = makeStyles(theme => ({
  margin: ({ mb, mt, ml, mr }) => ({
    marginBottom: mb === undefined ? undefined : theme.spacing(mb),
    marginTop: mt === undefined ? undefined : theme.spacing(mt),
    marginLeft: ml === undefined ? undefined : theme.spacing(ml),
    marginRight: mr === undefined ? undefined : theme.spacing(mr)
  })
}));

const TypographyWithMargin = React.forwardRef(function TypographyWithMargin(
  { className, mb, ml, mt, mr, ...other },
  ref
) {
  const classes = useStyles({ mb, ml, mt, mr });
  return (
    <Typography
      {...other}
      className={clsx(className, classes.margin)}
      ref={ref}
    />
  );
});
export default TypographyWithMargin;

and then this can be used as follows:

import React from "react";
import Typography from "./TypographyWithMargin";

export default function App() {
  return (
    <>
      <Typography mb={3} variant="h5" color="primary">
        Some Text
      </Typography>
      <Typography ml={2} color="primary">
        Later Text
      </Typography>
    </>
  );
}

Edit Box imitation

I've applied this, by Heydon Pickering.

I created a component Vertical.js:

import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Box } from '@material-ui/core';

const useStyles = makeStyles((theme) => ({
  vertical: {
    '& > *+*': {
      marginTop: '1.5rem',
    },
  },
}));

const Vertical = ({ children }) => {
  const classes = useStyles();
  return <Box className={classes.vertical}>{children}</Box>;
};

export default Vertical;

Then use it in any other components e.g. Example.js:

import React from 'react';
import Vertical from './Vertical';

const Example = () => {
  return (
    <Vertical>
      <Component/>
      <Component />
      <Another />
      <AnotherComponent />
    </Vertical>
  );
};

export default Example;

You could avoid creating a bunch of duplicate CSS classes by sharing a makeStyles hook with some margin utility classes among your components:

// useMargins.js

import { makeStyles } from "@material-ui/core/styles"

export const useMargins = makeStyles((theme) => ({
  mt2: { marginTop: theme.spacing(2) },
  ml2: { marginLeft: theme.spacing(2) },
  mr2: { marginRight: theme.spacing(2) },
  mb2: { marginBottom: theme.spacing(2) },
}))

// MyComponent.js

import * as React from "react"
import Typography from "@material-ui/core/Typography"
import { useMargins } from "./useMargins"

export default function MyComponent(props): React.Node {
  const { mb2 } = useMargins()
  return <Typography className={mb2}>Hello</Typography>
}
Related