SSGOI LogoSSGOI
← 블로그로 돌아가기
Nuxt로 네이티브 앱 같은 웹앱 만들기

Nuxt로 네이티브 앱 같은 웹앱 만들기

문대승
ssgoinuxtvuepage-transitiontutorial

웹으로 앱을 만들고 싶다면?

웹 기술로 앱을 만들 수 있다는 거 알고 계셨나요? Nuxt로 웹앱을 만들고, Expo나 Flutter의 웹뷰로 감싸면 바로 앱스토어에 출시할 수 있습니다.

하지만 웹앱의 가장 큰 약점이 있습니다. 바로 페이지 전환이 어색하다는 것.

네이티브 앱은 화면 전환이 자연스럽습니다. 리스트에서 상세로 들어갈 때 슬라이드되고, 이미지를 탭하면 확대되면서 열리죠. 하지만 웹은? 페이지가 그냥 뚝 바뀝니다.

만약 웹에서도 네이티브 같은 페이지 전환이 가능하다면? 그 웹앱은 진짜 앱처럼 느껴질 겁니다.

오늘은 Nuxt로 네이티브 앱 같은 페이지 트랜지션이 들어간 웹앱을 만들어봅니다.

SSGOI란?

SSGOI(쓱오이)는 모든 브라우저에서 동작하는 페이지 트랜지션 라이브러리입니다.

Chrome의 View Transition API가 있지만, Safari와 Firefox에서는 동작하지 않습니다. SSGOI는 이 문제를 해결합니다.

SSGOI의 장점:

  • 모든 모던 브라우저 지원 (Chrome, Firefox, Safari, Edge)
  • SSR/SSG 완벽 호환
  • 스프링 기반 물리 애니메이션으로 자연스러운 움직임
  • 다양한 빌트인 트랜지션 (Drill, Slide, Pinterest, Instagram 등)

시작하기

SSGOI 공식 템플릿으로 시작해봅시다.

# 레포지토리 클론
git clone https://github.com/meursyphus/ssgoi

# 루트에서 의존성 설치 (중요!)
pnpm install

# Nuxt 템플릿 폴더로 이동
cd templates/nuxt

# 개발 서버 실행
pnpm run dev

주의: pnpm install은 반드시 루트 경로에서 실행해야 합니다. 모노레포 구조라서 루트에서 전체 의존성을 설치합니다.

브라우저에서 http://localhost:3000을 열면 템플릿 데모를 확인할 수 있습니다.

폴더 구조 살펴보기

templates/nuxt/ 폴더 구조입니다.

templates/nuxt/
├── app.vue                  # 루트 앱 컴포넌트
├── pages/
│   ├── posts/               # Drill 트랜지션 예제
│   │   ├── index.vue
│   │   └── [id].vue
│   ├── pinterest/           # Pinterest 트랜지션 예제
│   │   ├── index.vue
│   │   └── [id].vue
│   ├── profile/             # Instagram 트랜지션 예제
│   │   ├── index.vue
│   │   └── [id].vue
│   └── products.vue         # Slide 트랜지션 예제
│       ├── all.vue
│       ├── electronics.vue
│       ├── fashion.vue
│       ├── home.vue
│       └── beauty.vue
├── components/
│   ├── demo-layout.vue      # Ssgoi 설정 (핵심!)
│   ├── demo-wrapper.vue     # 모바일 프레임 UI
│   ├── pin-card.vue
│   ├── post-card.vue
│   ├── profile-feed.vue
│   └── products/
└── composables/             # 데이터 관리
    ├── use-posts.ts
    ├── use-pinterest.ts
    ├── use-profile.ts
    └── use-products.ts

핵심 파일은 components/demo-layout.vue입니다. 모든 트랜지션 설정이 여기에 있습니다.

Ssgoi 설정하기

components/demo-layout.vue를 열어보세요.

<template>
  <main>
    <Ssgoi :config="config">
      <slot />
    </Ssgoi>
  </main>
</template>

<script setup lang="ts">
import { Ssgoi } from '@ssgoi/vue';
import type { SsgoiConfig } from '@ssgoi/vue';
import {
  drill,
  pinterest,
  instagram,
} from '@ssgoi/vue/view-transitions';

const config: SsgoiConfig = {
  transitions: [
    // Pinterest 트랜지션
    {
      from: '/pinterest/*',
      to: '/pinterest',
      transition: pinterest(),
      symmetric: true,
    },
    // Posts - Drill 트랜지션
    {
      from: '/posts',
      to: '/posts/*',
      transition: drill({ direction: 'enter' }),
    },
    {
      from: '/posts/*',
      to: '/posts',
      transition: drill({ direction: 'exit' }),
    },
    // Profile - Instagram 트랜지션
    {
      from: '/profile',
      to: '/profile/*',
      transition: instagram(),
      symmetric: true,
    },
  ],
};
</script>

config 구성:

  • from: 출발 경로 (*는 와일드카드)
  • to: 도착 경로
  • transition: 사용할 트랜지션
  • symmetric: 역방향도 같은 트랜지션 적용

SsgoiTransition으로 페이지 감싸기

각 페이지는 SsgoiTransition으로 감싸고 고유한 id를 부여해야 합니다.

pages/posts/index.vue를 보면:

<template>
  <SsgoiTransition id="/posts">
    <div class="min-h-full bg-[#121212] px-4 py-6">
      <!-- 포스트 목록 -->
      <div class="space-y-2">
        <NuxtLink
          v-for="post in posts"
          :key="post.id"
          :to="`/posts/${post.id}`"
        >
          <!-- 포스트 카드 -->
        </NuxtLink>
      </div>
    </div>
  </SsgoiTransition>
</template>

<script setup lang="ts">
import { SsgoiTransition } from '@ssgoi/vue';

const posts = useAllPosts();
</script>

pages/posts/[id].vue를 보면:

<template>
  <SsgoiTransition :id="`/posts/${id}`">
    <div class="min-h-screen bg-[#121212]">
      <!-- 포스트 상세 -->
    </div>
  </SsgoiTransition>
</template>

<script setup lang="ts">
import { SsgoiTransition } from '@ssgoi/vue';

const route = useRoute();
const id = route.params.id as string;

const post = usePost(id);
</script>

핵심: id는 config의 from/to와 매칭되어야 SSGOI가 트랜지션을 적용합니다.

Drill 트랜지션

리스트에서 상세로 들어가는 느낌의 트랜지션입니다.

Drill 트랜지션 - 리스트에서 상세로 들어갔다가 나오기

템플릿 위치: pages/posts/

import { drill } from '@ssgoi/vue/view-transitions';

// 리스트 → 상세 (들어갈 때)
drill({ direction: 'enter' });

// 상세 → 리스트 (나올 때)
drill({ direction: 'exit' });

config 설정:

{
  from: '/posts',
  to: '/posts/*',
  transition: drill({ direction: 'enter' }),
},
{
  from: '/posts/*',
  to: '/posts',
  transition: drill({ direction: 'exit' }),
},

옵션:

  • direction: "enter" | "exit"
  • opacity: true면 페이드 효과 추가

Slide 트랜지션

탭 UI에서 좌우로 슬라이드하는 효과입니다.

Slide 트랜지션 - 탭 클릭 시 좌우 슬라이드

템플릿 위치: pages/products.vue

import { slide } from '@ssgoi/vue/view-transitions';

// 왼쪽으로 슬라이드
slide({ direction: 'left' });

// 오른쪽으로 슬라이드
slide({ direction: 'right' });

pages/products.vue에서는 중첩된 Ssgoi를 사용해 탭 영역만 트랜지션합니다:

<template>
  <SsgoiTransition id="/products">
    <!-- 헤더, 탭 버튼 -->
    <div class="flex-1 overflow-hidden">
      <Ssgoi :config="config">
        <NuxtPage />
      </Ssgoi>
    </div>
  </SsgoiTransition>
</template>

<script setup lang="ts">
import { Ssgoi, SsgoiTransition } from '@ssgoi/vue';
import type { SsgoiConfig } from '@ssgoi/vue';
import { slide } from '@ssgoi/vue/view-transitions';

const categories = [
  { id: 'all', label: 'All', path: '/products/all' },
  { id: 'electronics', label: 'Tech', path: '/products/electronics' },
  { id: 'fashion', label: 'Fashion', path: '/products/fashion' },
  { id: 'home', label: 'Home', path: '/products/home' },
  { id: 'beauty', label: 'Beauty', path: '/products/beauty' },
];

const config: SsgoiConfig = {
  transitions: [
    {
      from: '/products/tab/left',
      to: '/products/tab/right',
      transition: slide({ direction: 'left' }),
    },
    {
      from: '/products/tab/right',
      to: '/products/tab/left',
      transition: slide({ direction: 'right' }),
    },
  ],
  middleware: (from: string, to: string) => {
    const fromIndex = categories.findIndex((c) => c.path === from);
    const toIndex = categories.findIndex((c) => c.path === to);

    if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
      if (fromIndex < toIndex) {
        return { from: '/products/tab/left', to: '/products/tab/right' };
      } else {
        return { from: '/products/tab/right', to: '/products/tab/left' };
      }
    }

    return { from, to };
  },
};
</script>

Pinterest 트랜지션

갤러리 이미지가 확대되면서 상세로 전환되는 효과입니다.

Pinterest 트랜지션 - 이미지가 확대되면서 상세로 전환

템플릿 위치: pages/pinterest/

Data 어트리뷰트 설정 (필수!)

Pinterest 트랜지션은 data 어트리뷰트로 어떤 이미지끼리 연결되는지 지정해야 합니다.

components/pin-card.vue (갤러리):

<template>
  <NuxtLink :to="`/pinterest/${item.id}`">
    <div class="relative" :style="{ aspectRatio: item.aspectRatio }">
      <img
        :src="item.image"
        :alt="item.title"
        class="w-full h-full object-cover"
        :data-pinterest-gallery-key="item.id"
      />
    </div>
  </NuxtLink>
</template>

<script setup lang="ts">
defineProps<{
  item: PinterestItem;
}>();
</script>

pages/pinterest/[id].vue (상세):

<template>
  <SsgoiTransition :id="`/pinterest/${id}`">
    <div v-if="item">
      <img
        :src="item.image"
        :alt="item.title"
        :style="{ aspectRatio: item.aspectRatio }"
        :data-pinterest-detail-key="item.id"
      />
    </div>
  </SsgoiTransition>
</template>

<script setup lang="ts">
import { SsgoiTransition } from '@ssgoi/vue';

const route = useRoute();
const id = route.params.id as string;

const item = usePinterestItem(id);
</script>

핵심: data-pinterest-gallery-keydata-pinterest-detail-key같은 id를 넣어야 SSGOI가 연결을 인식합니다.

config 설정:

{
  from: '/pinterest/*',
  to: '/pinterest',
  transition: pinterest(),
  symmetric: true,
}

Instagram 트랜지션

프로필 피드 그리드에서 상세로 전환되는 효과입니다. Pinterest와 비슷하지만, 갤러리가 그대로 유지됩니다.

Instagram 트랜지션 - 그리드에서 상세로 전환, 갤러리 유지

템플릿 위치: pages/profile/

Data 어트리뷰트 설정

components/post-card.vue (그리드):

<template>
  <NuxtLink :to="`/profile/${post.id}`">
    <img
      :src="post.coverImage.url"
      :alt="post.title"
      class="w-full h-auto object-cover"
      :data-instagram-gallery-key="post.id"
    />
  </NuxtLink>
</template>

<script setup lang="ts">
defineProps<{
  post: ProfilePost;
}>();
</script>

pages/profile/[id].vue (상세):

<template>
  <SsgoiTransition :id="`/profile/${id}`">
    <div v-if="post">
      <img
        :src="post.coverImage.url"
        :alt="post.title"
        :data-instagram-detail-key="post.id"
      />
    </div>
  </SsgoiTransition>
</template>

<script setup lang="ts">
import { SsgoiTransition } from '@ssgoi/vue';

const route = useRoute();
const id = route.params.id as string;

const post = useProfilePost(id);
</script>

config 설정:

{
  from: '/profile',
  to: '/profile/*',
  transition: instagram(),
  symmetric: true,
}

Spring 옵션으로 타이밍 조절하기

모든 트랜지션은 스프링 기반 물리 애니메이션을 사용합니다. spring 옵션으로 속도와 느낌을 조절할 수 있습니다.

drill({
  direction: 'enter',
  spring: {
    stiffness: 200, // 높을수록 빠름
    damping: 20, // 높을수록 덜 튕김
    doubleSpring: true, // ease-in-out 효과 켜기
  },
});

Spring 옵션 설명

옵션설명효과
stiffness강성높을수록 빠르고 즉각적
damping감쇠높을수록 덜 튕기고 부드러움
doubleSpring더블 스프링true/false - ease-in-out 효과

doubleSpring이란? CSS의 ease-in-out처럼 시작과 끝이 부드러운 효과입니다. 자세한 내용은 Double Spring 블로그 글을 참고하세요.

유용한 옵션들

symmetric 옵션

양방향 트랜지션을 자동 설정합니다.

// 이렇게 하나만 설정하면
{
  from: '/pinterest/*',
  to: '/pinterest',
  transition: pinterest(),
  symmetric: true,
}

// 역방향도 자동 적용됩니다

와일드카드 라우트

*로 모든 하위 경로를 매칭합니다.

// /posts/1, /posts/abc 모두 매칭
{ from: '/posts', to: '/posts/*', ... }

마무리

이제 여러분도 Nuxt 웹앱에 네이티브 같은 페이지 트랜지션을 적용할 수 있습니다!

리소스

웹뷰로 감싸서 진짜 앱으로 만들어보세요!