【Next.js】ポートフォリオにモーションを付ける方法を解説

公開日: 2026年9月12日 · 読了目安 13 分

Next.js で作るポートフォリオサイトに、作品を引き立てるモーションを付ける方法をコード付きで解説。大きな1件+グリッド、スクロールで作品が切り替わるギャラリー、ケーススタディの入場、モーションデザイナー向けの動画作品の載せ方まで。

ポートフォリオのモーションは作品を見る順番に使う

Next.js でポートフォリオサイトを作っていて、制作実績のページが「ただのサムネイル一覧」に見える。動きを足したいけれど、何を動かせばよいか分からない。

そんなときは、作品そのものではなく「作品を見る順番」を動きで作ってください。

パーティクルの背景やカーソル追従のような演出が先に目に入ると、案件の写真が負けます。ポートフォリオで効くのは、スクロールで作品が切り替わるギャラリーと、ケーススタディの控えめな入場です。

この記事で分かることは次のとおりです。

  • 制作実績が弱く見える理由

  • 大きな1件と静的なグリッドで、見せたい作品を決める方法

  • スクロールに合わせて作品が切り替わる、ピン留めギャラリーの作り方

  • ケーススタディのページに付ける、弱い入場アニメーション

  • モーションデザイナーが動画作品を載せるときの注意

ポートフォリオの制作実績が弱く見える3つの理由

1つ目は、同じ大きさのサムネイルが並んでいるだけなことです。どの作品も同じ扱いだと、見る人は一番の自信作がどれか分かりません。

1件だけ大きくするだけで、ページに入口ができます。

2つ目は、自動で流れるカルーセルに作品を入れていることです。依頼を考えている人は、気になった作品で止まりたいのに、勝手に次へ進みます。

作品の切り替えは、見る人の操作(スクロールやクリック)に任せます。

3つ目は、演出がケーススタディの本文より長いことです。ページを開いてから課題や役割が読めるまでに何秒も待たされると、採用担当や依頼主は離れます。

動きは、読み始めを邪魔しない長さに抑えます。

方法1:ポートフォリオを大きな1件+グリッドで見せる

一番見せたい案件を大きく置き、残りは静的なグリッドに並べます。動きは、マウスを乗せたときに画像がわずかに拡大するだけです。

サーバーコンポーネント(サーバーで組み立てる部品)のまま作れるので、"use client"(ブラウザで動かす印)は要りません。

画像は next/image で読み込み、sizes を実際の表示幅に合わせます。大きな1件だけ priority を付けると、最初の表示が速くなります。

tsx
import Image from "next/image";
  import Link from "next/link";
  
  type Work = { slug: string; title: string; role: string; image: string; alt: string };
  
  export function WorkGrid({ works }: { works: Work[] }) {
    const [featured, ...rest] = works;
  
    return (
      <section className="mx-auto max-w-6xl px-6 py-24">
        <h2 className="text-3xl font-semibold">制作実績</h2>
        <div className="mt-10 grid gap-6 md:grid-cols-3">
          <Link href={"/works/" + featured.slug} className="group md:col-span-2 md:row-span-2">
            <div className="relative aspect-[4/3] overflow-hidden rounded-2xl bg-zinc-100">
              <Image
                src={featured.image}
                alt={featured.alt}
                fill
                priority
                sizes="(min-width: 768px) 66vw, 100vw"
                className="object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03] motion-reduce:transition-none"
              />
            </div>
            <p className="mt-3 font-medium">{featured.title}</p>
            <p className="text-sm text-zinc-500">{featured.role}</p>
          </Link>
          {rest.map((work) => (
            <Link key={work.slug} href={"/works/" + work.slug} className="group">
              <div className="relative aspect-[4/3] overflow-hidden rounded-2xl bg-zinc-100">
                <Image
                  src={work.image}
                  alt={work.alt}
                  fill
                  sizes="(min-width: 768px) 33vw, 100vw"
                  className="object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03] motion-reduce:transition-none"
                />
              </div>
              <p className="mt-3 font-medium">{work.title}</p>
              <p className="text-sm text-zinc-500">{work.role}</p>
            </Link>
          ))}
        </div>
      </section>
    );
  }

作品カードのホバーは画像だけを拡大する

カードごと膨らませると、隣の作品と重なって見えたり、文字までぼやけたりします。

overflow-hidden の枠の中で画像だけを 1.03 倍程度に拡大すると、落ち着いた反応になります。

並べ方の工夫は https://design-layer.com/ja/articles/nextjs-bento-grid-guide 、ホバーの調整は https://design-layer.com/ja/articles/nextjs-hover-card-guide で詳しく解説しています。

方法2:ポートフォリオのピン留めギャラリー

セクションを画面に固定し、スクロールした量に合わせて「今見せている作品」を切り替えます。作品が1枚ずつ主役になるので、案件の数が少なくても見応えが出ます。

枚数は4〜8件が扱いやすい範囲です。

スクロールの進み具合は、Motion(旧 Framer Motion)の2つの仕組みで扱います。

  • useScroll:スクロールがどこまで進んだかを読み取る

  • useMotionValueEvent:読み取った値を、今見せる作品の番号に変える

外側のセクションの高さは「作品数 × 画面の高さ」にします。内側を sticky(スクロールしても止まって見える配置)で固定するのが基本の形です。

tsx
"use client";
  
  import { useRef, useState } from "react";
  import Image from "next/image";
  import { motion, useMotionValueEvent, useScroll } from "motion/react";
  
  const works = [
    { slug: "brand-site", title: "ブランドサイト制作", image: "/works/brand.jpg", alt: "ブランドサイトのトップページ" },
    { slug: "app-ui", title: "アプリの UI デザイン", image: "/works/app.jpg", alt: "スマホアプリの画面3枚" },
    { slug: "motion-reel", title: "モーションリール 2026", image: "/works/reel.jpg", alt: "モーションリールの1場面" },
    { slug: "ec-renewal", title: "EC サイトのリニューアル", image: "/works/ec.jpg", alt: "EC サイトの商品一覧ページ" },
  ];
  
  export function PinnedGallery() {
    const ref = useRef<HTMLElement>(null);
    const [active, setActive] = useState(0);
    const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
  
    useMotionValueEvent(scrollYProgress, "change", (value) => {
      setActive(Math.min(works.length - 1, Math.floor(value * works.length)));
    });
  
    return (
      <section ref={ref} className="relative" style={{ height: works.length * 100 + "vh" }}>
        <div className="sticky top-0 flex h-screen flex-col items-center justify-center px-6">
          <div className="relative aspect-[4/3] w-full max-w-4xl overflow-hidden rounded-2xl bg-zinc-100">
            {works.map((work, i) => (
              <motion.div
                key={work.slug}
                className="absolute inset-0"
                initial={false}
                animate={{ opacity: i === active ? 1 : 0 }}
                transition={{ duration: 0.4, ease: "easeOut" }}
              >
                <Image src={work.image} alt={work.alt} fill sizes="(min-width: 1024px) 896px, 100vw" className="object-cover" />
              </motion.div>
            ))}
          </div>
          <p className="mt-6 text-lg font-medium">{works[active].title}</p>
          <p className="text-sm text-zinc-500">
            {active + 1} / {works.length}
          </p>
        </div>
      </section>
    );
  }

ギャラリーが画面に固定されないときの確認点

親の要素に overflow: hidden や overflow: auto が付いていると、ギャラリーは固定されません。

レイアウト全体を包む要素に overflow-hidden を付けていないか、最初に確認します。

スマホでは、画面の高さがアドレスバーの出入りで変わります。h-screen の代わりに h-dvh を使うと、ずれが出にくくなります。

ギャラリーの作り方全体は https://design-layer.com/ja/articles/nextjs-scroll-gallery-guide で、スマホ対応も含めて解説しています。

ポートフォリオのケーススタディは弱い入場だけにする

作品の詳細ページは、課題と役割、工夫したことと結果を読んでもらう場所です。ここでの動きは、段落や画像が画面に入ったときに少し浮き上がる程度で十分です。

使うのは、Motion の whileInView(画面に入ったら動かす指定)です。

1回だけ動かすには、viewport に once: true を指定します。

動きを減らす設定の人には、useReducedMotion を使って最初から表示します。

tsx
"use client";
  
  import type { ReactNode } from "react";
  import { motion, useReducedMotion } from "motion/react";
  
  export function FadeIn({ children }: { children: ReactNode }) {
    const reduce = useReducedMotion();
  
    return (
      <motion.div
        initial={reduce ? false : { opacity: 0, y: 12 }}
        whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: true, margin: "-10% 0px" }}
        transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
      >
        {children}
      </motion.div>
    );
  }

ケーススタディの見出しと最初の画像は動かさない

ページを開いて最初に見える見出しとメインの画像には、入場を付けません。

最初の数秒を待たせず、すぐに作品が見える方が、依頼主にも採用担当にも伝わります。FadeIn で包むのは、スクロールした先の段落と補足の画像だけにします。

モーションデザイナーのポートフォリオに動画を載せる

モーションデザイナーや映像制作者のポートフォリオでは、作品そのものが動いています。

サイト側の演出は、さらに控えめにしてください。サイトの動きが作品の動きと競うと、どちらも伝わりにくくなります。

動画をたくさん並べるときは、全部を同時に再生しないことが大事です。画面に入った動画だけを再生し、外れたら止めます。

muted と playsInline を付けると、スマホでも多くの場合は自動で再生できます。

省電力モードなどで止められることもあるので、poster の静止画だけでも作品が伝わるようにしておきます。

poster に静止画を指定し、preload は metadata に留めると、ページの読み込みが重くなりません。

tsx
"use client";
  
  import { useEffect, useRef } from "react";
  
  export function WorkVideo({ src, poster, label }: { src: string; poster: string; label: string }) {
    const ref = useRef<HTMLVideoElement>(null);
  
    useEffect(() => {
      const video = ref.current;
      if (!video) return;
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  
      const observer = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting) {
            video.play().catch(() => {});
          } else {
            video.pause();
          }
        },
        { threshold: 0.5 }
      );
      observer.observe(video);
      return () => observer.disconnect();
    }, []);
  
    return (
      <video
        ref={ref}
        src={src}
        poster={poster}
        muted
        loop
        playsInline
        preload="metadata"
        aria-label={label}
        className="aspect-video w-full rounded-2xl bg-zinc-900 object-cover"
      />
    );
  }

長い動画作品は短いループと本編に分ける

一覧には 5〜10 秒程度の短いループを置き、本編はケーススタディのページで再生ボタン付きで見せます。一覧に長い動画を並べると、通信量が増え、スマホでの表示が遅くなります。

ポートフォリオのモーションでよくある失敗

  • カーソル追従やパーティクルを全ページに置き、作品より演出が目立つ

  • 自動カルーセルで作品が流れ、見たい作品で止められない

  • 画像の sizes を指定せず、スマホでも大きな画像を読み込んで重くなる

  • 作品画像の alt が空、またはファイル名のままになっている

  • クライアントワークの画像を、掲載の許可を確認せずに載せている

最後の2つは、動きとは関係ありませんが、公開前に必ず確認してください。alt には「何の案件の、どの画面か」を短く書きます。

完成したポートフォリオ用ギャラリーを使う方法

DesignLayer の「スタンダードデザイン・作品グリッド」の詳細ページ。左でライブプレビューを確かめ、右の「Cursor 用にコピー」でソースをコピーできる
DesignLayer の「スタンダードデザイン・作品グリッド」の詳細ページ。左でライブプレビューを確かめ、右の「Cursor 用にコピー」でソースをコピーできる

ピン留めと切り替えのタイミングを自分で調整すると、スマホでの確認まで含めて半日かかることがあります。作品の準備に時間を使いたいなら、完成したギャラリーを土台にする方法もあります。

DesignLayer は、人が組んだ React + Tailwind CSS のモーション付きセクションを、ブラウザでライブプレビューしてからコピーできるカタログです。

ギャラリーを実際にスクロールして動きを確かめ、画像とタイトルだけを自分の作品に差し替えます。

閲覧にアカウントもカードも要りません。https://design-layer.com/ja/components

無料アカウントでは、無料サンプル10個を無料アカウントで回数制限なくコピーでき、動画プロンプトは月1回です。Free では MCP に接続できません。カタログ全体は30日ビルドパス($19の1回払い、自動更新なし)、Lifetime Access(先着100名の$79の1回払い)、Plus(月額 $9、いつでも解約可)です。無料サンプルなら回数の上限なくコピーできます。それ以外は30日ビルドパス($19)、Lifetime($79)、Plus(月額 $9)です。

ポートフォリオのモーションについてよくある質問

  • 大きな1件+グリッドだけなら不要です。Tailwind CSS のホバーで足ります。

    スクロールで作品が切り替わるギャラリーや、画面に入ったときの入場を付けるときに使います。

    導入は https://design-layer.com/ja/articles/nextjs-framer-motion-guide を参考にしてください。

まとめ:Next.js のポートフォリオにモーションを付ける手順

  • ポートフォリオのモーションは、作品を見る順番を作るために使う

  • 一番の作品を大きく置き、残りは静的なグリッドにする

  • 作品が少ないなら、スクロールで切り替わるピン留めギャラリーが効く

  • ケーススタディは弱い入場だけにし、最初の見出しと画像は動かさない

  • 動画作品は、画面に入ったものだけを muted で再生する

まずは制作実績のページで、一番見せたい1件を大きくするところから始めてください。それだけで、どこから見ればよいかが伝わるページになります。

関連ガイド