리액트 너머, 공유 요소 트랜지션을 직접 구현해봤습니다

타락한스벨트전도사·2026년 7월 12일
post-thumbnail

인스타그램이나 구글 포토에서 사진을 누르면, 방금 누른 이미지가 다음 화면까지 이어집니다. 예전에는 앱에서 주로 보던 움직임인데 요즘은 웹에서도 어렵지 않게 만날 수 있습니다. Airbnb 웹에서 숙소 카드를 누를 때도 목록의 사진이 상세 화면까지 자연스럽게 이어집니다.

이런 움직임을 보통 Shared Element Transition, 공유 요소 애니메이션이라고 부릅니다. 페이지는 바뀌지만 사용자가 선택한 대상은 그대로 남아, 두 화면 사이의 맥락을 이어 줍니다.

저도 처음에는 출발 요소와 도착 요소의 위치와 크기만 구하면 된다고 생각했습니다. 단색 박스와 비율이 같은 이미지는 실제로 잘 움직였습니다. 그런데 여러 화면에 적용해보니 생각보다 단순하지 않았습니다.

이 글에서는 제가 처음 그린 단순한 모델에서 출발해, 실제 사진에서 생긴 에러를 하나씩 따라가 보려고 합니다. 시작 화면도 맞고 도착 화면도 맞는데 둘 사이만 이상했습니다. 대체 무엇을 빼먹었던 걸까요?

화면이 바뀌어도 같은 것은 이어져야 한다

Shared Element Transition의 핵심은 두 화면에 공통으로 존재하는 대상을 시각적으로 이어 주는 데 있습니다.

상품 목록에서 누른 사진이 상세 이미지로 커지고, 작은 음악 플레이어가 전체 플레이어로 펼쳐지고, 프로필 목록의 아바타가 다음 화면의 헤더로 이동합니다. 사용자는 이 움직임을 보면서 “내가 누른 것이 이것으로 이어졌구나”라고 이해합니다.

그래서 저는 shared animation을 단순한 장식으로 보지 않습니다. 갑자기 새 화면을 보여 주는 대신, 사용자가 보고 있던 대상을 남겨 두어 이동의 맥락을 설명하는 장치에 가깝습니다.

Chrome 공식 문서에 있는 썸네일 → 큰 이미지 전환도 같은 예시입니다.

이런 전환은 다음 상황에서 특히 잘 맞습니다.

  • 상품 목록 → 상품 상세
  • 사진 그리드 → 사진 상세
  • 프로필 목록 → 프로필 화면
  • 미니 플레이어 → 전체 플레이어
  • 접힌 카드 → 펼쳐진 카드

공통점은 하나입니다. 페이지가 바뀐 뒤에도 사용자가 선택한 대상을 계속 알아볼 수 있다는 것입니다. 그렇다면 웹에서는 이 연결을 어떻게 만들 수 있을까요?

웹에서는 View Transition API로 시작할 수 있다

웹에도 이를 위한 View Transition API가 있습니다. 리액트 전용 API는 아닙니다. 같은 문서 안에서 DOM을 바꾼다면 상태를 바꾸는 함수를 document.startViewTransition()으로 감싸면 됩니다.

function updateView() {
  if (!document.startViewTransition) {
    updateDOM();
    return;
  }

  document.startViewTransition(() => updateDOM());
}

지원하지 않는 브라우저에서는 평소처럼 DOM만 바뀝니다. View Transition은 페이지 이동의 성공 조건이 아니라, 이미 작동하는 화면 변경 위에 얹는 시각적 향상에 가깝습니다.

브라우저는 변경 전 화면과 변경 후 화면을 캡처하고, 그 사이를 전환합니다. 특정 요소를 따로 이어 주고 싶다면 양쪽 요소에 같은 view-transition-name을 붙입니다.

.thumbnail {
  view-transition-name: product-image;
}

.detail-image {
  view-transition-name: product-image;
}

두 요소가 같은 DOM 노드일 필요도 없습니다. 목록의 <img>와 상세 화면의 다른 <img>에 같은 이름을 주면, 브라우저는 둘을 같은 시각적 대상으로 다룹니다.

단, 한 화면에 렌더된 두 요소가 같은 view-transition-name을 동시에 가지면 전환을 시작할 수 없습니다. 목록에 카드가 여러 개라면 클릭한 카드에만 임시 이름을 주거나, 각 요소에 고유한 이름을 붙여야 합니다.

브라우저 안에서는 다음 순서가 일어납니다.

  1. 현재 화면을 old 상태로 캡처합니다.
  2. callback을 실행해 DOM을 바꿉니다.
  3. 바뀐 화면을 new 상태로 캡처합니다.
  4. 두 캡처를 별도의 레이어에 올리고 CSS Animation으로 전환합니다.

예전에는 전환을 위해 이전 DOM과 다음 DOM을 한 화면에 함께 두고 직접 관리해야 했습니다. View Transition은 실제 문서를 먼저 새 상태로 바꾸고, 시각적인 중간 장면은 의사 요소 트리에서 만듭니다.

::view-transition
└─ ::view-transition-group(product-image)
   └─ ::view-transition-image-pair(product-image)
      ├─ ::view-transition-old(product-image)
      └─ ::view-transition-new(product-image)

group은 위치와 크기, transform을 담당합니다. 그 안의 oldnew가 이전 캡처와 다음 캡처입니다. 기본값은 두 화면의 cross-fade지만, 이 의사 요소에 CSS를 붙이면 각자 다른 애니메이션을 줄 수 있습니다.

::view-transition-old(product-image),
::view-transition-new(product-image) {
  animation-duration: 400ms;
  mix-blend-mode: normal;
}

ViewTransition 객체에는 updateCallbackDone, ready, finished가 따로 있습니다. DOM 변경이 끝난 시점, 의사 요소가 준비된 시점, 애니메이션까지 끝난 시점을 구분해 후속 작업을 붙일 수 있습니다.

SPA가 아니어도 쓸 수 있다

View Transition은 리액트나 SPA에만 묶여 있지 않습니다. 같은 origin의 문서끼리 이동하는 MPA라면 양쪽 페이지에 CSS 한 줄만 더해 참여할 수 있습니다.

@view-transition {
  navigation: auto;
}

이 경우에는 document.startViewTransition()을 직접 부르지 않습니다. 사용자가 링크를 눌러 다른 문서로 이동할 때 브라우저가 전환을 시작합니다. SPA와 MPA는 시작 방법만 다르고, old/new 캡처와 view-transition-name을 다루는 기본 모델은 같습니다.

View Transition API는 이미지 비율이 달라지는 경우도 다룰 수 있습니다. old/new 스냅샷은 <img> 같은 replaced element로 취급되기 때문에 object-fitobject-position을 직접 줄 수 있습니다. 다만 어떤 fit과 crop을 써야 하는지는 전환을 만드는 사람이 정해야 합니다.

브라우저가 이런 기능을 제공하더라도, 공유 요소 전환의 기본 원리는 결국 출발 요소와 도착 요소를 잇는 데서 시작합니다. 우선 가장 단순한 경우부터 보겠습니다.

단순한 요소는 bbox만 봐도 충분했다

Shared Element Transition의 가장 단순한 모델은 두 개의 bbox를 잇는 것입니다. bbox는 getBoundingClientRect()로 얻는 요소의 사각형이라고 생각하면 됩니다.

from bbox → translate + scale → to bbox

가령 100×100짜리 파란 박스가 300×200짜리 박스로 커진다고 해보겠습니다. 시작점과 끝점의 중심 차이로 translate를 구하고, 너비와 높이의 비율로 scale을 구하면 됩니다.

박스 안의 내용도 박스를 꽉 채운 단색이라면 아무 문제가 없습니다. 요소를 맞추는 일이 곧 내용을 맞추는 일이니까요.

저도 처음에는 이 모델로 충분하다고 생각했습니다. 하지만 실제 공유 요소에는 사진이 자주 들어갑니다. 사진의 원본 비율은 박스의 비율과 다를 수 있고, 그 차이를 처리하는 순간부터 문제가 복잡해집니다.

object-fit이 들어오면 박스와 사진이 달라진다

사진이 들어간 목록과 상세 화면을 예로 들어보겠습니다.

목록에서는 크기가 제각각인 사진을 같은 크기의 카드에 넣어야 합니다. 그래서 썸네일에는 보통 object-fit: cover를 씁니다. 빈 공간 없이 카드를 채우는 대신, 사진의 일부를 잘라냅니다.

상세 화면에서는 사진 전체를 보여 주고 싶습니다. 이때는 object-fit: contain을 씁니다. 원본 비율을 지키면서 박스 안에 사진 전체를 넣기 때문에 위아래나 양옆에 공간이 남을 수 있습니다.

  • fill: 박스에 맞춰 너비와 높이를 바꿉니다. 원본 비율이 다르면 사진이 찌그러집니다.
  • contain: 원본 비율을 지키면서 사진 전체가 박스 안에 들어오게 합니다.
  • cover: 원본 비율을 지키면서 박스를 전부 덮습니다. 넘친 부분은 잘립니다.

Chrome 문서도 1:1 썸네일과 16:9 이미지 사이의 전환을 별도로 다룹니다. 기본 View Transition은 old/new 스냅샷의 비율을 보존하지만, 그것만으로 작성자가 원하는 crop 변화까지 정해주지는 않습니다.

출처: Chrome for Developers, CC BY 4.0

공식 예제는 의사 요소에 object-fit과 clipping 규칙을 직접 지정합니다.

::view-transition-old(full-embed),
::view-transition-new(full-embed) {
  animation: none;
  mix-blend-mode: normal;
  height: 100%;
  overflow: clip;
}

::view-transition-old(full-embed) {
  object-fit: contain;
}

::view-transition-new(full-embed) {
  object-fit: cover;
}

즉, View Transition API로도 이 문제를 해결할 수 있습니다. 브라우저는 이미지 비율과 crop이 바뀌는 상황을 다룰 수단을 제공하고, 작성자가 어느 화면에 covercontain을 적용할지 CSS로 지정합니다.

제가 구현하던 환경은 조금 달랐습니다. ssgoi는 View Transition API 대신 자체 clone과 Web Animations API로 전환을 만듭니다. 브라우저의 View Transition 지원 여부와 관계없이 여러 프레임워크와 라우터에서 다양한 전환을 제공하기 위해서입니다.

이 자체 엔진에서 썸네일의 cover가 상세 화면의 contain으로 이어질 때 문제가 나타났습니다. 양쪽 끝에서는 원본 비율을 지키고 있었지만, bbox만 기준으로 clone을 확대하니 전환 중간의 사진이 박스 비율을 따라간 것처럼 보였습니다.

첫 프레임과 마지막 프레임은 멀쩡한데 중간만 이상했습니다. 출발과 도착의 bbox가 모두 맞다면, 이제 박스 안에서 실제 사진이 차지하는 영역을 의심해봐야 했습니다.

사진에는 사각형이 두 개 있다

이 문제를 풀려면 요소 안에 사진이 어떻게 놓였는지 따로 봐야 했습니다. 저는 이를 content rectvisible window로 나눴습니다.

  • content rect: 원본 비율과 object-fit을 적용한 뒤, 사진 전체가 실제로 놓인 영역
  • visible window: 그 사진 중에서 지금 사용자에게 보이는 영역

contain에서는 대체로 둘이 같습니다. 사진 전체가 보이기 때문입니다.

반면 cover에서는 content rect가 카드보다 큽니다. 사진은 카드 바깥까지 놓여 있고, 카드의 bbox가 그중 일부만 보여 주는 창이 됩니다.

왼편은 작은 썸네일 컨테이너에 cover로 사진을 넣은 목록 화면입니다. 검은 테두리가 실제로 보이는 창이고, 파란 점선은 카드 바깥까지 놓인 원본 사진의 content rect입니다. 오른편 상세 화면에서는 같은 사진 전체가 보입니다.

여기서 bbox끼리만 맞추면 사진 속 같은 지점이 같은 경로를 따라간다는 보장이 없습니다. 먼저 content rect끼리 맞춘 뒤, 그 위에서 visible window가 변하도록 만들어야 합니다. 문제는 이 정보를 라이브러리 사용자가 매번 직접 넘기게 할 것인지였습니다.

ssgoi는 그 규칙을 자동으로 추론한다

ssgoi의 Hero와 Zoom은 목록의 요소를 상세 화면까지 이어 주는 기능입니다. View Transition API에서는 작성자가 old/new 의사 요소에 fit을 지정하지만, ssgoi를 쓰는 사람에게 화면마다 같은 정보를 다시 적게 하고 싶지는 않았습니다.

처음에는 사용하는 쪽에서 원본 비율과 radius를 직접 넘기게 했습니다. 동작은 했지만 꽤 번거로웠습니다. 마크업에는 이미 <img>와 CSS가 있는데, 라이브러리 옵션에 같은 정보를 한 번 더 적고 있었거든요.

그래서 최근 Hero와 Zoom에서는 이 정보를 DOM에서 자동으로 읽도록 바꿨습니다. 다만 아무 이미지나 깊게 찾아다니지는 않고, 다음처럼 구조를 확실히 해석할 수 있는 경우만 봅니다.

  1. key를 붙인 요소가 직접 <img>인지 확인합니다.
  2. wrapper라면 직계 자식 <img>가 정확히 하나인지 확인합니다.
  3. naturalWidth / naturalHeight로 원본 비율을 구합니다.
  4. 아직 로드 전이라면 width, height 속성을 대신 봅니다.
  5. computed style에서 object-fit: cover | contain을 읽습니다.
  6. object-position이 가운데인지 확인합니다.
  7. wrapper의 overflow: hidden | clip과 단순한 border radius를 읽습니다.

여기까지 읽을 수 있으면 bbox와 원본 비율을 가지고 content rect를 계산할 수 있습니다.

contain은 사진 전체가 bbox 안에 들어오는 쪽을 택합니다. bbox가 원본보다 넓으면 높이를 맞추고, bbox가 원본보다 좁으면 너비를 맞춥니다.

cover는 반대입니다. bbox를 빈틈없이 덮는 쪽을 택합니다. bbox가 원본보다 넓으면 너비를 맞추고, bbox가 원본보다 좁으면 높이를 맞춥니다.

이 계산을 마치면 썸네일 카드 밖으로 잘려 나간 부분을 포함해, 사진의 실제 크기와 위치를 알 수 있습니다. 이제 박스가 아니라 이 사진 영역끼리 연결할 차례입니다.

scale은 박스가 아니라 사진끼리 맞춘다

Hero에서는 도착 화면의 이미지 clone을 만든 뒤, 시작 화면의 content rect에 맞춰 놓습니다. 시작 프레임의 scale은 다음처럼 구합니다.

const scale = Math.max(
  fromContent.width / toContent.width,
  fromContent.height / toContent.height,
);

가로와 세로에 서로 다른 scale을 주지 않고 하나만 사용합니다. 같은 원본 사진이라면 두 content rect의 비율도 같으므로 가로와 세로에서 거의 같은 값이 나옵니다. 둘 중 큰 값을 택해 아주 작은 측정 오차가 있어도 content를 덮도록 했습니다.

위치도 bbox의 좌상단끼리 맞추지 않습니다. 두 content rect의 중심 차이를 구합니다.

const dx = centerX(fromContent) - centerX(toContent);
const dy = centerY(fromContent) - centerY(toContent);

이렇게 하면 cover로 잘린 썸네일과 contain으로 들어간 상세 이미지가 서로 다른 박스 안에 있어도, 사진 자체의 중심과 비율을 기준으로 움직입니다.

여기까지 계산하면 사진의 위치와 scale은 맞습니다. 하지만 시작부터 사진 전체를 보여 주면 썸네일에서 잘렸던 영역이 첫 프레임에 갑자기 나타납니다. 사진을 제대로 움직이는 것만으로는 부족하고, 사진을 보여 주는 창도 함께 움직여야 합니다.

scale은 사진을 움직이고, clip-path는 창을 움직인다

이때 visible window를 함께 움직이는 역할을 clip-path가 맡습니다.

ssgoi는 시작 화면의 crop window를 도착 화면의 content 좌표계로 역산합니다. 쉽게 말하면 “도착 이미지 clone에서 어느 부분만 보여 주면 시작 썸네일과 똑같아지는가”를 구하는 겁니다.

그 결과를 clip-path: inset()으로 표현합니다.

clip-path: inset(top right bottom left round radius);

전환이 진행되면 시작 clip inset에서 도착 clip inset까지 값을 보간합니다. 처음에는 썸네일에서 보이던 부분만 남기고, 상세 화면에 가까워질수록 잘렸던 영역을 보여 줍니다.

둥근 모서리도 같이 계산했습니다. clone에 border-radius: 16px을 그대로 두면 scale이 2배일 때 화면에서는 32px로 보입니다. 그래서 화면에서 보여야 할 radius를 먼저 보간하고, 현재 scale로 나눈 값을 clip-path에 넣습니다.

const clipRadius = visibleRadius / currentScale;

정리하면 이렇습니다.

scale과 translate는 사진을 움직이고, clip-path는 사진을 보는 창을 움직입니다.

확실히 알 수 있는 경우에만 자동으로 계산했다

자동 추론을 넣기 시작하면 욕심이 생깁니다. DOM 아래의 이미지를 전부 찾고, picturevideo도 보고, 모든 object-position과 radius를 지원하고 싶어집니다.

그런데 한쪽만 잘못 읽어도 bbox만 썼을 때보다 결과가 이상했습니다. 그래서 범위를 꽤 보수적으로 잡았습니다.

  • <picture><video>
  • 직계 자식 이미지가 여러 개인 구조
  • 깊어서 어떤 이미지가 공유 대상인지 모호한 구조
  • 가운데가 아닌 custom object-position
  • 서로 다른 원본 비율
  • 단순한 px 값으로 해석할 수 없는 radius
  • 한쪽 endpoint에서만 이미지 정보를 읽은 경우

이런 경우에는 양쪽 모두 content-aware 계산을 포기하고 기존 bbox 전환으로 돌아갑니다. 한쪽만 보정하지도 않습니다.

움직임이 조금 단순한 것은 지나칠 수 있어도, 전환 중에 사람 얼굴이 넓어졌다가 돌아오면 바로 눈에 띄었습니다. 그래서 라이브러리가 확실히 아는 범위에서만 개입하는 편을 택했습니다.

이미 있던 해법을 자체 엔진에 자동으로 옮겼다

구현을 마친 뒤 살펴보니 View Transition API도 같은 문제를 이미 고려하고 있었습니다. 비율이 다른 old/new 스냅샷에 object-fit을 주고, 넘치는 영역을 가리면서 crop을 바꿉니다. 제가 ssgoi에서 도달한 결론과 방향이 같았습니다.

차이는 누가 그 규칙을 정하느냐였습니다. View Transition API에서는 작성자가 전환마다 의사 요소의 fit과 clipping을 지정합니다. ssgoi에서는 실제 DOM의 <img>, 원본 비율, computed object-fit, clipping wrapper와 radius를 읽고 같은 결정을 자동으로 내립니다.

처음 구현도 수학적으로 틀린 것은 아니었습니다. 출발 bbox와 도착 bbox는 정확했습니다. 다만 사용자가 보고 있던 것은 박스가 아니라 그 안의 사진이었습니다.

그래서 사진 전체가 놓인 content rect를 기준으로 scale과 위치를 맞추고, visible window는 clip-path로 따로 보간했습니다. 자동으로 확신할 수 없는 구조라면 양쪽 모두 기존 bbox 방식으로 돌아갔습니다.

리액트 안에서 컴포넌트를 연결하는 것만 볼 때는 만나기 어려운 문제였습니다. DOM이 화면에 어떤 크기로 그려졌는지, 이미지가 그 박스 안에서 실제로 어디에 놓였는지까지 내려가고 나서야 이유가 보였습니다.

Shared Element Transition은 두 박스를 잇는 일로 시작합니다. 하지만 그 안에 사진이 들어가면 같은 픽셀이 어디에 있는지, 그중 어디까지 보여 줄지를 함께 계산해야 합니다. 브라우저 표준과 제가 만든 자체 엔진도 결국 그 지점에서 같은 답에 도착했습니다.

ssgoi에서는 이렇게 동작합니다

이번에 만든 자동 추론 로직은 ssgoi의 Hero와 Zoom에 그대로 들어갔습니다. 전환마다 이미지 비율과 radius를 따로 넘기지 않아도, 일반적인 <img> 구조라면 라이브러리가 DOM과 CSS를 읽고 알아서 계산합니다.

ssgoi는 제가 만들고 있는 오픈소스 페이지 전환 라이브러리입니다. React와 Next.js뿐 아니라 Svelte, Vue 등 여러 환경에서 페이지 이동에 애니메이션을 붙일 수 있습니다. Hero 외에도 drill, scroll, sheet 같은 전환을 같은 방식으로 사용할 수 있습니다.

글에서 설명한 Hero와 Zoom은 ssgoi.dev에서 직접 확인할 수 있고, 구현 코드는 GitHub에 공개해두었습니다. 이 기능은 제가 만든 서비스 컴윗(comwit.io)에도 적용했습니다.

공유 요소 전환을 구현하고 있다면, 여기서 다룬 content rect와 visible window의 구분이 비슷한 문제를 찾는 데 작은 힌트가 되었으면 합니다. ssgoi가 궁금하다면 데모도 가볍게 둘러봐 주세요.

profile
기부하면 코드 드려요

0개의 댓글