이번에 배울 것
여러 컴포넌트에 애니메이션 전파하기
복잡한 레이아웃 에니메이션 오케스트레이션(조정 및 통합)
Propagation (애니메이션 전파)
부모 컴포넌트의 variant 변경을 자식 컴포넌트에 알림
- 카드와 레이블을 덮고 있는 "보이지 않는" 모션 레이어가 있습니다. 이 레이어는 "hover" variant를 설정하는 whileHover 소품을 보유합니다.
- "글로우" 자체도 컴포넌트이지만 정의하는 유일한 것은 호버 키가 있는 자체 variant 객체입니다.
- 보이지 않는 레이어가 "hover"되면 hover variant를 토글하고 hover variant가 prop에 정의되어 있는 모든 하위 모션 컴포넌트는 이 변경 사항을 감지하고 해당 동작을 토글합니다.
const CardWithGlow = () => {
const glowVariants = {
initial: {
opacity: 0
},
hover: {
opacity: 1
}
}
return (
// 부모는 초기 상태와 whileHover 상태의 variant key를 설정합니다.
<motion.div initial="initial" whileHover="hover">
{/* 자식 모션 컴포넌트는 부모가 설정한 키와 일치하는 variant에 따라 애니메이션을 적용합니다. */}
<motion.div variants={glowVariants} className="glow"/>
<Card>
<div>Some text on the card/div>
</Card>
</motion.div>
)
}
컴포넌트 unmount시 애니메이션 적용하기
- AnimatePresence를 활용한다.
- exit prop을 활용한다.
레이아웃 애니메이션
지금까지 배운 것
- 모션 컴포넌트 세트 전체에 애니메이션 전파
- 정상적으로 마운트 해제할 수 있도록 구성 요소에 exit transition 추가
아래에서 다룰 것 외에 추가로 더 보기 Everything about Framer Motion layout animations
레이아웃 애니메이션이란?
-
position properties
-
flex or grid properties
-
width or height
-
sorting elements
프레이머 div에 layout 프롬 하나만 전달하면 됩니다.
이렇게 하면 별개의 레이아웃 간에 모션 컴포넌트를 애니메이션 할 수 있습니다.
(justifyContent가 전환됩니다!)
const SwitchWrapper2 = styled('div', {
width: '50px',
height: '30px',
borderRadius: '20px',
cursor: 'pointer',
display: 'flex',
background: '#111',
justifyContent: 'flex-start',
'&[data-isactive="true"]': {
background: '#f90566',
justifyContent: 'flex-end',
},
});
const SwitchHandle2 = styled(motion.div, {
background: '#fff',
width: '30px',
height: '30px',
borderRadius: '50%',
});
// Simpler version of the Switch motion component using layout animation
const Switch2 = () => {
const [active, setActive] = React.useState(false);
return (
<SwitchWrapper2
data-isactive={active}
onClick={() => setActive((prev) => !prev)}
>
<SwitchHandle2 layout />
</SwitchWrapper2>
);
};
const Component = () => (
<div style={{ maxWidth: '300px' }}>
Switch 2: Animating justify-content using layout animation and the layout prop.
<Switch2 />
</div>
);
리스트에서 삭제할 땐 layout="position"하면 크기는 바로 전환되서 자연스러움
Shared Layout Animation
원이 자연스럽게 이동하게 하기

const MagicWidgetComponent = () => {
const [selectedID, setSelectedID] = React.useState('1');
return (
<ul>
{items.map((item) => (
<li
style={{
position: 'relative'
}}
key={item.id}
onClick={() => setSelectedID(item.id)}
>
<Circle>{item.photo}</Circle>
{selectedID === item.id && (
<motion.div
layoutId="border" // -------------------- here!!
style={{
position: 'absolute',
borderRadius: '50%',
width: '48px',
height: '48px',
border: '4px solid blue';
}}
/>
)}
</li>
))}
</Grid>
);
-
using the layout prop on the ListItem component to animate reordering the list
-
using the layout prop on the list itself to handle resizing gracefully when items are expanded when clicked on
-
other instances of the layout prop used to prevent glitches during a layout animation (especially the ones involving changing the height of a list item)
- 레이아웃 그룹 안에서 레이아웃 프롭이 있는 모션 컴포넌트의 레이아웃 애니메이션은 동기화 됩니다.
const List = styled(motion.ul, {
padding: '16px',
width: '350px',
background: 'var(--maximeheckel-colors-body)',
borderRadius: 'var(--border-radius-1)',
display: 'grid',
gap: '16px',
});
const ListItem = styled(motion.li, {
background: 'var(--maximeheckel-colors-foreground)',
boxShadow: '0 0px 10px -6px rgba(0, 24, 40, 0.3)',
borderRadius: 'var(--border-radius-1)',
padding: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
marginBottom: '0px',
});
const Button = styled('button', {
background: 'transparent',
border: 'none',
shadow: 'none',
color: 'var(--maximeheckel-colors-typeface-tertiary)',
});
const InfoBox = styled('div', {
width: '50%',
});
const FilterWrapper = styled('div', {
marginBottom: '16px',
input: {
marginRight: '4px',
},
label: {
marginRight: '4px',
},
});
const Title = motion.div;
const ARTICLES = [
{
category: 'swift',
title: 'Intro to SwiftUI',
description: 'An article with some SwitftUI basics',
id: 1,
},
{
category: 'js',
title: 'Awesome React stuff',
description: 'My best React tips!',
id: 2,
},
{
category: 'js',
title: 'Styled components magic',
description: 'Get to know ways to use styled components',
id: 3,
},
{
category: 'ts',
title: 'A guide to Typescript',
description: 'Type your React components!',
id: 4,
},
];
const categoryToVariant = {
js: 'warning',
ts: 'info',
swift: 'danger',
};
const Item = (props) => {
const { article, showCategory, expanded, onClick } = props;
const readButtonVariants = {
hover: {
opacity: 1,
},
initial: {
opacity: 0,
},
};
return (
<ListItem layout initial="initial" whileHover="hover" onClick={onClick}>
<InfoBox>
// 타이틀 높이 조절 시 부드럽게... 다른 레이아웃들과 동기화됨.
<Title
layout
>
{article.title}
</Title>
<AnimatePresence>
{expanded && (
<motion.div
style={{ fontSize: '12px' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{article.description}
</motion.div>
)}
</AnimatePresence>
</InfoBox>
<AnimatePresence>
{showCategory && (
<motion.div
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Pill variant={categoryToVariant[article.category]}>
{article.category}
</Pill>
</motion.div>
)}
</AnimatePresence>
<motion.div
layout
variants={readButtonVariants}
transition={{ duration: 0.25 }}
>
<Button
aria-label="read article"
title="Read article"
onClick={(e) => e.preventDefault()}
>
→
</Button>
</motion.div>
</ListItem>
);
};
const Component = () => {
const [showCategory, setShowCategory] = React.useState(false);
const [sortBy, setSortBy] = React.useState('title');
const [expanded, setExpanded] = React.useState(null);
const onSortChange = (event) => setSortBy(event.target.value);
const articlesToRender = ARTICLES.sort((a, b) => {
const itemA = a[sortBy].toLowerCase();
const itemB = b[sortBy].toLowerCase();
if (itemA < itemB) {
return -1;
}
if (itemA > itemB) {
return 1;
}
return 0;
});
return (
<>
<FilterWrapper>
<div>
<input
type="checkbox"
id="showCategory2"
checked={showCategory}
onChange={() => setShowCategory((prev) => !prev)}
/>
<label htmlFor="showCategory2">Show Category</label>
</div>
<div>
Sort by:{' '}
<input
type="radio"
id="title"
name="sort"
value="title"
checked={sortBy === 'title'}
onChange={onSortChange}
/>
<label htmlFor="title">Title</label>
<input
type="radio"
id="category"
name="sort"
value="category"
checked={sortBy === 'category'}
onChange={onSortChange}
/>
<label htmlFor="category">Category</label>
</div>
</FilterWrapper>
// 해당 그룹의 레이아웃 애니메이션들이 동기화 되도록 해줍니다.
<LayoutGroup>
<List layout>
{articlesToRender.map((article) => (
<Item
key={article.id}
expanded={expanded === article.id}
onClick={() => setExpanded(article.id)}
article={article}
showCategory={showCategory}
/>
))}
</List>
</LayoutGroup>
</>
);
};
render(<Component />);
AnimatePresence
- 각 아이템은 layout prop이 true로 설정된 모션 컴포넌트에 래핑됩니다.
- 전체 리스트는 AnimatePresence로 래핑되고, 각 아이템에 exit 애니메이션이 포함될 수 있습니다.
- 목록에서 알림을 클릭하면 알림이 제거되고 레이아웃 애니메이션 덕분에 스택이 자동으로 다시 조정됩니다.
<motion.div
key={item}
onClick={() =>
setNotifications((prev) =>
prev.filter((notification) => notification !== item)
)
}
layout
initial={{
y: 150,
x: 0,
opacity: 0
}}
animate={{
y: 0,
x: 0,
opacity: 1
}}
exit={{
opacity: 0
}}
>
<Toast>{item}</Toast>
</motion.div>
Transition 오브젝트의 layout 키 내에서 레이아웃 애니메이션의 전환을 사용자 정의할 수 있습니다.
<motion.div
layout
transition={{
layout: {
duration: 1.5,
},
}}
/>
Fixing distortions
운 좋게도 이러한 문제를 해결할 수 있는 쉬운 해결 방법이 있습니다.
다음과 같이 이러한 속성을 인라인 스타일로 설정합니다.
// CSS
.box {
width: 20px;
height: 20px;
border-radius: 20px;
}
.box[data-expanded="true"] {
width: 150px;
height: 150px;
}
// JS
<motion.div
layout
className="box"
data-expanded={expanded}
/>
저처럼 코드베이스에서 CSS 변수를 사용하고 있다면
borderRadius 또는 boxShadow 값에 대해 CSS 변수를 설정해도 위의 부작용이 수정되지 않는다는 점에 유의하십시오.
왜곡을 방지하려면 적절한 값을 사용해야 합니다.
layout prop에 대해 더 알아보기
- layout="position": 위치 관련 속성만 부드럽게 전환합니다. 크기 관련 속성은 빠르게 전환됩니다.
- layout="size": 크기 관련 속성만 부드럽게 전환합니다. 위치 관련 속성은 빠르게 전환됩니다.


- 가로 목록에서 항목을 제거하면 각 컴포넌트의 크기에 영향을 줍니다. 항목을 제거하면 컴포넌트가 약간 찌그러지는 것을 알 수 있습니다.
- 모션 컴포넌트로 콘텐츠를 래핑하고 레이아웃을 position로 설정하면 모션 블록의 콘텐츠에서 관찰할 수 있는 모든 왜곡이 수정됩니다.
- 각 구성 요소는 보다 자연스러운 전환으로 크기가 적절하게 조정됩니다.

<motion.div layout>
<Label variant="success">
<motion.div
style={{
width: '100%',
display: 'flex',
justifyContent: 'start',
}}
layout="position"
>
<DismissButton/>
<span>{text}</span>
</motion.div>
</Label>
</motion.div>
Shared Layout Animation And LayoutGroup
여러 컴포넌트를 포함하는 레이아웃의 애니메이션
- 위 둘은 관련되어 있는 것처럼 보이지만 각가 매우 뚜렷한 목적과 사용 사례를 가지고 있습니다.
- 공유(shared) 측면은 마치 동일한 것처럼 한 위치에서 다른 위치로 콤포넌트가가 이동하는 효과입니다.
Shared layout animations
이전에 본 레이아웃 애니메이션의 또 다른 유형이라고 생각할 수 있지만 약간의 변형이 있습니다.
틀린 말은 아니지만 정확하지도 않습니다.
- 모든 Arrow 컴포넌트 인스턴스 사이에 transition을 적용합니다
- 사용자가 새 항목을 클릭할 때 한 인스턴스에서 새로운 "활성" 인스턴스로 전환해야 함을 알려주는 공통 layoutId를 공유합니다.

공유(shared) 측면은 마치 동일한 것처럼 한 위치에서 다른 위치로 콤포넌트가가 이동하는 효과입니다.
그 뒤에 숨겨진 "마법"은 실제로 매우 간단합니다.
- 다른 아이템을 클릭하면 화면에 표시된 Arrow 컴포넌트가 사라지고 Arrow 컴포넌트의 새 인스턴스가 나타납니다.
- 새로운 Arrow 컴포넌트는 목록에서 새로 선택한 요소 아래에 위치하게 될 컴포넌트입니다.
- 그런 다음 해당 컴포넌트가 최종 위치로 transition됩니다.
다른걸 만들어 봅시다.
- selected indicator
- hover highligh
- 각 레이아웃 애니메이션은 고유의 layoutId 프롭이 필요합니다 : underline & highlight
{focused === item ? (
<motion.div
transition={{
layout: {
duration: 0.2,
ease: "easeOut"
}
}}
style={{
position: "absolute",
bottom: "-2px",
left: "-10px",
right: 0,
width: "140%",
height: "110%",
background: "#23272F",
borderRadius: "8px",
zIndex: 0
}}
layoutId="highlight"
/>
) : null}
{selected === item ? (
<motion.div
style={{
position: "absolute",
bottom: "-10px",
left: "0px",
right: 0,
height: "4px",
background: "#5686F5",
borderRadius: "8px",
zIndex: 0
}}
layoutId="underline"
/>
) : null}
한 페이지에서 같은 컴포넌트를 두번 사용한다면? > 설명하기 어려운 버그가 발생합니다.
LayoutGroup: 사용 사례에 namespace 제공
- shared layout 애니메이션을 활용하고 동일한 페이지 내에서 해당 컴포넌트를 사용하는 재사용 가능한 컴포넌트를 빌드할 수 있는 네임스페이스 by layoutId
- 전체 레이아웃에 영향을 줄 수 있는 고유한 레이아웃 애니메이션을 수행하는 sibilling 컴포넌트를 그룹화하여 새로 업데이트된 레이아웃에 적절하게 적응할 수 있습니다.
shared 레이아웃 애니메이션에 네임스페이스를 부여하는 것은 ayoutGroup의 유일한 사용 사례가 아닙니다.
원래 목적은 실제로 다음과 같습니다.
레이아웃 애니메이션을 함께 수행해야 하는 모션 컴포넌트를 그룹화합니다.
- 반드시 모션 컴포넌트가 아닙니다.
- 상호 작용하지 않았기 때문에 렌더링되지 않습니다.
- 다시 렌더링하지 않기 때문에 애니메이션이 정의된 경우에도 자체적으로 레이아웃 애니메이션을 수행할 수 없습니다.

- 첫 번째 목록에서 항목을 제거하면 첫 번째 목록 내의 항목이 부드러운 레이아웃 애니메이션을 수행하지만 두 번째 목록이 갑자기 움직이는 것을 확인하세요
- LayoutGroup 래핑을 켜면 이제 첫 번째 목록에서 항목을 제거할 때 두 번째 목록이 대상 위치로 부드럽게 전환되는 것을 확인합니다.
<LayoutGroup>
<List
items={[...]}
name="List 1"
/>
<List
items={[...]}
name="List 2"
/>
</LayoutGroup>
Reorder
드래그 앤 드롭 후 재정렬 시 부드럽게 이동하는 것은 레이아웃 애니메이션의 최고의 사용 사례 중 하나입니다.
- Reorder.Group에 리스트, 재정렬 방향(가로 또는 세로) 및 리스트의 최신 순서를 반환하는 onReorder 콜백을 전달합니다.
- Reorder.Item에 리스트에 있는 아이템의 값을 전달합니다.
const MyList = () => {
const [items, setItems] = React.useState(['Item 1', 'Item 2', 'Item 3']);
return (
<Reorder.Group
// Specify the direction of the list (x for horizontal, y for vertical)
axis="y"
// Specify the full set of items within your reorder group
values={items}
// Callback that passes the newly reordered list of item
// Note: simply passing a useState setter here is equivalent to
// doing `(reordereditems) => setItmes(reordereditems)`
onReorder={setItems}
>
{items.map((item) => (
// /!\ don't forget the value prop!
<Reorder.Item key={item} value={item}>
{item}
</Reorder.Item>
))}
</Reorder.Group>
);
};
- 각 Reorder.Item은 모션 컴포넌트입니다.
- 목록의 각 Reorder.Item 컴포넌트는 기본적으로 레이아웃 애니메이션을 수행할 수 있습니다.
항상 드래그 중인 컴포넌트를 상단으로 위치하고 싶다면, Reorder.Item의 CSS를 relative로 설정합니다.
CSS-IN-JS의 다형성 활용하기
참고로 위는 되고 아래는 안됨
// Valid
<Reorder.Group as="span" />
<Reorder.Item as="div" />
<Reorder.Item as="aside" />
// Invalid
<Reorder.Group as={List} />
<Reorder.Item as={Card} />
따라서 아래와 같이 씀
const Card = styled('div', {...});
// ...
// Valid Custom Reorder component
<Card as={Reorder.Item} />
배운걸 전부 써먹어보기
- layout="position"은 항목을 선택하고 레이아웃 애니메이션을 수행할 때 왜곡을 방지하기 위해 각 항목의 내용에 사용됩니다.
- 커스텀 React styled-component는 다형성을 통해 컴포넌트 재정렬을 사용합니다.
//...
<Card
as={Reorder.Item}
//...
value={item}
>
<Card.Body as={motion.div} layout="position">
<Checkbox
id={`checkbox-${item.id}`}
aria-label="Mark as done"
checked={item.checked}
onChange={() => completeItem(item.id)}
/>
<Text>{item.text}</Text>
</Card.Body>
</Card>
//...
- 항목 크기 조정 시 왜곡을 방지하기 위해 항목의 borderRadius 인라인 스타일이 사용됩니다.
- position: relative가 Reorder.Item에 인라인 스타일로 추가되어 목록의 요소를 서로 드래그하는 동안 발생하는 겹침 문제를 수정했습니다.
- AnimatePresence는 엘리먼트가 리스트에서 제거될 때 종료 애니메이션을 허용하는 데 사용됩니다.
//...
<AnimatePresence>
{items.map((item) => (
<motion.div
exit={{ opacity: 0, transition: { duration: 0.2 } }}
/>
<Card
as={Reorder.Item}
style={{
position: 'relative', // this is needed to avoid weird overlap
borderRadius: '12px', // this is set as inline styles to avoid distortions
width: item.checked ? '70%' : '100%', // will be animated through layout animation
}}
value={item}
>
//...
</Card>
</motion.div>
//...
)}
</AnimatePresence>
//...
- 리스트와 그 sibiling 컴포넌트는 목록이 전체 레이아웃을 업데이트하고 변경할 때 부드러운 레이아웃 애니메이션을 수행하기 위해 LayoutGroup에 래핑됩니다.
전체 구현은 : my blog's Github repository 에서 참조하세요
참고 :
https://blog.maximeheckel.com/posts/advanced-animation-patterns-with-framer-motion/
Advanced animation patterns with Framer Motion - Maxime Heckel's Blog
A deep dive into Framer Motion's propagation, exit transitions and layout animation patterns through curated examples and interactive playgrounds.
blog.maximeheckel.com
https://blog.maximeheckel.com/posts/framer-motion-layout-animations/
Everything about Framer Motion layout animations - Maxime Heckel's Blog
A complete guide to Framer Motion layout animations showcasing every concept, from the layout prop, shared layout animations and LayoutGroup, to complex drag-to-reorder interactions.
blog.maximeheckel.com
'FrontEnd' 카테고리의 다른 글
리액트에 SOLID 원칙 적용하기 (0) | 2022.07.17 |
---|---|
리액트 쿼리 : FAQ(자주 묻는 질문) (2) | 2022.07.17 |
프레이머 모션[Framer Motion] 기초 1편 : 생기초 알아보기 (0) | 2022.07.15 |
[css] fixed를 중첩할 경우 조심해야 할 점 (0) | 2022.07.14 |
리액트 성능 최적화 : children props를 이용하여 리렌더링 방지 (0) | 2022.07.09 |