Transitions

Transitions (트랜지션)

트랜지션은 UI를 표현력 있게 만들고 사용하기 쉽게 도와줘요. Material UI는 애플리케이션에 기본적인 motion을 도입할 수 있는 여러 트랜지션 컴포넌트를 제공해요. Collapse, Fade, Grow, Slide, Zoom이 주인공이에요.

출처: 문서

본문

Material UI는 애플리케이션에 기본적인 motion을 도입할 수 있는 트랜지션을 제공해요.

Collapse

자식 요소의 시작 가장자리에서 펼쳐져요. 가로 방향으로 접고 싶다면 orientation prop을 사용하세요. 접히지 않았을 때의 최소 너비/높이를 설정하려면 collapsedSize prop을 사용할 수 있어요.

import * as React from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Paper from '@mui/material/Paper';
import Collapse from '@mui/material/Collapse';
import FormControlLabel from '@mui/material/FormControlLabel';

const icon = (
  <Paper sx={{ m: 1, width: 100, height: 100 }} elevation={4}>
    <svg width="100" height="100">
      <Box
        component="polygon"
        points="0,100 50,00, 100,100"
        sx={(theme) => ({
          fill: theme.palette.common.white,
          stroke: theme.palette.divider,
          strokeWidth: 1,
        })}
      />
    </svg>
  </Paper>
);

export default function SimpleCollapse() {
  const [checked, setChecked] = React.useState(false);

  const handleChange = () => {
    setChecked((prev) => !prev);
  };

  return (
    <Box sx={{ height: 300 }}>
      <FormControlLabel
        control={<Switch checked={checked} onChange={handleChange} />}
        label="Show"
      />
      <Box
        sx={{
          '& > :not(style)': {
            display: 'flex',
            justifyContent: 'space-around',
            height: 120,
            width: 250,
          },
        }}
      >
        <div>
          <Collapse in={checked}>{icon}</Collapse>
          <Collapse in={checked} collapsedSize={40}>
            {icon}
          </Collapse>
        </div>
        <div>
          <Box sx={{ width: '50%' }}>
            <Collapse orientation="horizontal" in={checked}>
              {icon}
            </Collapse>
          </Box>
          <Box sx={{ width: '50%' }}>
            <Collapse orientation="horizontal" in={checked} collapsedSize={40}>
              {icon}
            </Collapse>
          </Box>
        </div>
      </Box>
    </Box>
  );
}

Fade

투명에서 불투명으로 페이드 인(fade in)해요.

import * as React from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Paper from '@mui/material/Paper';
import Fade from '@mui/material/Fade';
import FormControlLabel from '@mui/material/FormControlLabel';

const icon = (
  <Paper sx={{ m: 1, width: 100, height: 100 }} elevation={4}>
    <svg width="100" height="100">
      <Box
        component="polygon"
        points="0,100 50,00, 100,100"
        sx={(theme) => ({
          fill: theme.palette.common.white,
          stroke: theme.palette.divider,
          strokeWidth: 1,
        })}
      />
    </svg>
  </Paper>
);

export default function SimpleFade() {
  const [checked, setChecked] = React.useState(false);

  const handleChange = () => {
    setChecked((prev) => !prev);
  };

  return (
    <Box sx={{ height: 180 }}>
      <FormControlLabel
        control={<Switch checked={checked} onChange={handleChange} />}
        label="Show"
      />
      <Box sx={{ display: 'flex' }}>
        <Fade in={checked}>{icon}</Fade>
      </Box>
    </Box>
  );
}

Grow

자식 요소의 중심에서 바깥으로 펼쳐지면서, 동시에 투명에서 불투명으로 페이드 인해요.

두 번째 예시는 transform-origin을 바꾸는 방법을 보여주고, 진입 속도를 바꾸기 위해 timeout prop을 조건부로 적용해요.

import * as React from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Paper from '@mui/material/Paper';
import Grow from '@mui/material/Grow';
import FormControlLabel from '@mui/material/FormControlLabel';

const icon = (
  <Paper sx={{ m: 1, width: 100, height: 100 }} elevation={4}>
    <svg width="100" height="100">
      <Box
        component="polygon"
        points="0,100 50,00, 100,100"
        sx={(theme) => ({
          fill: theme.palette.common.white,
          stroke: theme.palette.divider,
          strokeWidth: 1,
        })}
      />
    </svg>
  </Paper>
);

export default function SimpleGrow() {
  const [checked, setChecked] = React.useState(false);

  const handleChange = () => {
    setChecked((prev) => !prev);
  };

  return (
    <Box sx={{ height: 180 }}>
      <FormControlLabel
        control={<Switch checked={checked} onChange={handleChange} />}
        label="Show"
      />
      <Box sx={{ display: 'flex' }}>
        <Grow in={checked}>{icon}</Grow>
        {/* Conditionally applies the timeout prop to change the entry speed. */}
        <Grow
          in={checked}
          style={{ transformOrigin: '0 0 0' }}
          {...(checked ? { timeout: 1000 } : {})}
        >
          {icon}
        </Grow>
      </Box>
    </Box>
  );
}

Slide

화면의 가장자리에서 슬라이드 인해요. direction prop은 트랜지션이 시작되는 화면의 가장자리를 제어해요.

mountOnEnter prop은 in이 true가 될 때까지 자식 컴포넌트가 마운트되지 않도록 막아요. 이렇게 하면 상대 위치(relatively positioned) 컴포넌트가 화면 밖 위치에서 스크롤되어 들어오는 것을 막아요. 마찬가지로 unmountOnExit prop은 컴포넌트가 화면 밖으로 트랜지션된 후 DOM에서 제거해요.

import * as React from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import FormControlLabel from '@mui/material/FormControlLabel';

const icon = (
  <Paper sx={{ m: 1, width: 100, height: 100 }} elevation={4}>
    <svg width="100" height="100">
      <Box
        component="polygon"
        points="0,100 50,00, 100,100"
        sx={(theme) => ({
          fill: theme.palette.common.white,
          stroke: theme.palette.divider,
          strokeWidth: 1,
        })}
      />
    </svg>
  </Paper>
);

export default function SimpleSlide() {
  const [checked, setChecked] = React.useState(false);

  const handleChange = () => {
    setChecked((prev) => !prev);
  };

  return (
    <Box sx={{ height: 180, width: 130, position: 'relative', zIndex: 1 }}>
      <FormControlLabel
        control={<Switch checked={checked} onChange={handleChange} />}
        label="Show"
      />
      <Slide direction="up" in={checked} mountOnEnter unmountOnExit>
        {icon}
      </Slide>
    </Box>
  );
}

컨테이너 기준 슬라이드 (Slide relative to a container)

Slide 컴포넌트는 DOM 노드에 대한 참조인 container prop도 받아요. 이 prop이 설정되면 Slide 컴포넌트는 해당 DOM 노드의 가장자리에서 슬라이드해요.

import * as React from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Paper from '@mui/material/Paper';
import Slide from '@mui/material/Slide';
import FormControlLabel from '@mui/material/FormControlLabel';

const icon = (
  <Paper sx={{ m: 1, width: 100, height: 100 }} elevation={4}>
    <svg width="100" height="100">
      <Box
        component="polygon"
        points="0,100 50,00, 100,100"
        sx={(theme) => ({
          fill: theme.palette.common.white,
          stroke: theme.palette.divider,
          strokeWidth: 1,
        })}
      />
    </svg>
  </Paper>
);

export default function SlideFromContainer() {
  const [checked, setChecked] = React.useState(false);
  const containerRef = React.useRef<HTMLElement>(null);

  const handleChange = () => {
    setChecked((prev) => !prev);
  };

  return (
    <Box
      sx={{
        width: 240,
        borderRadius: 2,
        border: '1px solid',
        borderColor: 'divider',
        backgroundColor: 'background.default',
      }}
    >
      <Box sx={{ p: 2, height: 200, overflow: 'hidden' }} ref={containerRef}>
        <FormControlLabel
          control={<Switch checked={checked} onChange={handleChange} />}
          label="Show from target"
        />
        <Slide in={checked} container={containerRef.current}>
          {icon}
        </Slide>
      </Box>
    </Box>
  );
}

Zoom

자식 요소의 중심에서 바깥으로 펼쳐져요.

이 예시는 진입 트랜지션을 지연시키는 방법도 보여줘요.

import * as React from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Paper from '@mui/material/Paper';
import Zoom from '@mui/material/Zoom';
import FormControlLabel from '@mui/material/FormControlLabel';

const icon = (
  <Paper sx={{ m: 1, width: 100, height: 100 }} elevation={4}>
    <svg width="100" height="100">
      <Box
        component="polygon"
        points="0,100 50,00, 100,100"
        sx={(theme) => ({
          fill: theme.palette.common.white,
          stroke: theme.palette.divider,
          strokeWidth: 1,
        })}
      />
    </svg>
  </Paper>
);

export default function SimpleZoom() {
  const [checked, setChecked] = React.useState(false);

  const handleChange = () => {
    setChecked((prev) => !prev);
  };

  return (
    <Box sx={{ height: 180 }}>
      <FormControlLabel
        control={<Switch checked={checked} onChange={handleChange} />}
        label="Show"
      />
      <Box sx={{ display: 'flex' }}>
        <Zoom in={checked}>{icon}</Zoom>
        <Zoom in={checked} style={{ transitionDelay: checked ? '500ms' : '0ms' }}>
          {icon}
        </Zoom>
      </Box>
    </Box>
  );
}

동작 줄이기 (Reduced motion)

트랜지션은 테마를 통해 축소된 동작(reduced-motion) 지원을 선택할 수 있어요:

const theme = createTheme({
  motion: {
    reducedMotion: 'system',
  },
});

활성화하면 Material UI 트랜지션 컴포넌트는 생명주기 콜백과 마운트/언마운트 동작을 유지해요. 축소된 동작이 활성화되면 진입하는 콘텐츠는 최종 상태로 나타나고, 나가는 콘텐츠는 일반 애니메이션 없이 사라져요. 컴포넌트는 여전히 동일한 콜백을 실행하므로 onEntered나 onExited를 기다리는 코드는 계속 작동해요.

특정 트랜지션에서 의도적으로 일반 동작을 유지해야 할 때만 disablePrefersReducedMotion을 사용하세요:

<Fade in disablePrefersReducedMotion>
  <div />
</Fade>

자식 요구사항 (Child requirement)

  • style 전달하기: 서버 렌더링을 더 잘 지원하기 위해 Material UI는 일부 트랜지션 컴포넌트(Fade, Grow, Zoom, Slide)의 자식에게 style prop을 제공해요. 애니메이션이 예상대로 작동하려면 style prop이 DOM에 적용되어야 해요.
  • ref 전달하기: 트랜지션 컴포넌트는 첫 번째 자식 요소가 자신의 ref를 DOM 노드로 전달해야 해요. ref에 대한 자세한 내용은 Caveat with refs를 확인하세요.
  • 단일 요소: 트랜지션 컴포넌트는 자식 요소가 하나만 필요해요(React.Fragment는 허용되지 않아요).
// The `props` object contains a `style` prop.
// You need to provide it to the `div` element as shown here.
const MyComponent = React.forwardRef(function (props, ref) {
  return (
    <div ref={ref} {...props}>
      Fade
    </div>
  );
});

export default function Main() {
  return (
    <Fade>
      {/* MyComponent must be the only child */}
      <MyComponent />
    </Fade>
  );
}

TransitionGroup

컴포넌트가 마운트되거나 언마운트될 때 애니메이션을 적용하려면 _react-transition-group_의 TransitionGroup 컴포넌트를 사용할 수 있어요. 컴포넌트가 추가되거나 제거되면 TransitionGroup이 in prop을 자동으로 토글해줘요.

import * as React from 'react';
import Button from '@mui/material/Button';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import DeleteIcon from '@mui/icons-material/Delete';
import { TransitionGroup } from 'react-transition-group';

const FRUITS = [
  '🍏 Apple',
  '🍌 Banana',
  '🍍 Pineapple',
  '🥥 Coconut',
  '🍉 Watermelon',
];

interface RenderItemOptions {
  item: string;
  handleRemoveFruit: (item: string) => void;
}

function renderItem({ item, handleRemoveFruit }: RenderItemOptions) {
  return (
    <ListItem
      secondaryAction={
        <IconButton
          edge="end"
          aria-label="delete"
          title="Delete"
          onClick={() => handleRemoveFruit(item)}
        >
          <DeleteIcon />
        </IconButton>
      }
    >
      <ListItemText primary={item} />
    </ListItem>
  );
}

export default function TransitionGroupExample() {
  const [fruitsInBasket, setFruitsInBasket] = React.useState(FRUITS.slice(0, 3));

  const handleAddFruit = () => {
    const nextHiddenItem = FRUITS.find((i) => !fruitsInBasket.includes(i));
    if (nextHiddenItem) {
      setFruitsInBasket((prev) => [nextHiddenItem, ...prev]);
    }
  };

  const handleRemoveFruit = (item: string) => {
    setFruitsInBasket((prev) => [...prev.filter((i) => i !== item)]);
  };

  const addFruitButton = (
    <Button
      variant="contained"
      disabled={fruitsInBasket.length >= FRUITS.length}
      onClick={handleAddFruit}
    >
      Add fruit to basket
    </Button>
  );

  return (
    <div>
      {addFruitButton}
      <List sx={{ mt: 1 }}>
        <TransitionGroup>
          {fruitsInBasket.map((item) => (
            <Collapse key={item}>{renderItem({ item, handleRemoveFruit })}</Collapse>
          ))}
        </TransitionGroup>
      </List>
    </div>
  );
}

Transition slots

많은 Material UI 컴포넌트가 내부적으로 이 트랜지션들을 사용해요. slots.transition과 slotProps.transition을 사용해 기본 트랜지션을 커스터마이징할 수 있어요. 위 컴포넌트 중 아무거나, 또는 직접 만든 트랜지션 구현을 사용할 수 있어요. 다음 조건을 지켜야 해요:

  • in prop을 받아요. 이는 열림/닫힘 상태에 해당해요.
  • 진입 트랜지션이 시작될 때 onEnter 콜백 prop을 호출해요.
  • 종료 트랜지션이 완료되면 onExited 콜백 prop을 호출해요. 이 두 콜백은 닫힌 상태에서 완전히 트랜지션된 후 자식을 언마운트할 수 있게 해줘요.

커스텀 트랜지션을 만드는 방법에 대한 자세한 내용은 _react-transition-group_의 Transition 문서를 방문하세요. 일부 컴포넌트의 전용 섹션도 확인할 수 있어요:

성능 & SEO (Performance & SEO)

트랜지션 컴포넌트의 콘텐츠는 in={false}여도 기본적으로 마운트돼요. 이 기본 동작은 서버 사이드 렌더링과 SEO를 염두에 둔 거예요. 트랜지션 안에 비싼 컴포넌트 트리를 렌더링한다면 unmountOnExit prop을 활성화해 이 기본 동작을 바꾸는 게 좋을 수 있어요:

<Fade in={false} unmountOnExit />

다른 성능 최적화와 마찬가지로 이것이 만능 해결책은 아니에요. 병목 지점을 먼저 파악한 다음 이 최적화 전략들을 시도해 보세요.

Collapse / Fade / Grow / Slide / Zoom API

임포트 (Import):

import Collapse from '@mui/material/Collapse';
import Fade from '@mui/material/Fade';
import Grow from '@mui/material/Grow';
import Slide from '@mui/material/Slide';
import Zoom from '@mui/material/Zoom';
// or
import { Collapse, Fade, Grow, Slide, Zoom } from '@mui/material';

주요 props 요약:

  • Collapse: collapsedSize, component, disablePrefersReducedMotion, easing, orientation('horizontal' | 'vertical'), timeout, slots, slotProps를 제공해요. ref는 루트 HTMLDivElement로 전달되고 Transition의 props도 상속받아요.
  • Fade: children(필수), appear, disablePrefersReducedMotion, easing, in, timeout을 제공해요. ref는 HTMLDivElement로 전달되고 Transition의 props를 상속받아요.
  • Grow: children(필수), appear, disablePrefersReducedMotion, easing, in, timeout(기본 'auto')을 제공해요.
  • Slide: children(필수), container, direction('down' | 'left' | 'right' | 'up'), disablePrefersReducedMotion, easing, in, timeout을 제공해요.
  • Zoom: children(필수), appear, disablePrefersReducedMotion, easing, in, timeout을 제공해요.

이 컴포넌트들은 ref를 루트 요소인 HTMLDivElement로 전달하고, 나머지 props는 루트 요소(Transition)에 제공돼요. 테마에서 MuiCollapse, MuiFade 등을 사용하면 기본 props를 바꿀 수 있어요.

더 알아보기 (Learn more)