Reactで、カードを裏返すアニメーションを作る方法

​​React​CSS,Playwright,TypeScript,アクセシビリティ

カードを裏返すには、表と裏の2つの面を同じ位置に重ね、裏の面だけを最初から rotateY(180deg) で裏返しておきます。そのうえで、2つの面を包む要素を Y 軸で 180 度回します。向こうを向いた面は backface-visibility: hidden で消すので、回り終わると裏の面だけが見えます。React の側で持つのは「裏返しているかどうか」の state 1つだけで、あとはクラス名の切り替えと、見えていない面を操作の対象から外す inert の付け替えです。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。回転の途中の様子は、document.getAnimations() で取り出した CSS の transition を一時停止し、currentTime を決めたミリ秒に合わせて読み取っています。読み上げの対象になる要素は、Chromium のアクセシビリティツリーを DevTools Protocol で書き出して調べました。アプリ全体を StrictMode で囲んで表裏を3回切り替えても、コンソールにエラーや警告は出ませんでした。Firefox と Safari、スマートフォンの実機、実際のスクリーンリーダーでは試していません。

英単語のカード。「裏を見る」を押すと、カードが横に回って「apple」の面から「りんご」の面に変わる。「表に戻す」で戻り、もう一度「裏を見る」で裏返る
ボタンを3回押して、カードを裏返したときの動き(上の回数表示、マウスの絵、押したボタンと新しく見えた単語のピンクは、GIF 用に足したもの)

表と裏を重ねて、外側ごと回す CSS

HTML の形は、外枠の .flip-card の中に回転させる .flip-card__inner があり、その中に表と裏の面が1つずつ入る形です。CSS は次のとおりです。

css
.flip-card {
  perspective: 1000px; /* 奥行き。小さいほど、回るときの遠近感が強くなる */
}

.flip-card__inner {
  display: grid; /* 2つの面を同じマスに重ねる */
  transform-style: preserve-3d; /* 子の面を、同じ3Dの空間に置く */
  transition: transform 0.6s ease;
}

.flip-card__inner.is-flipped {
  transform: rotateY(180deg);
}

.flip-card__face {
  grid-area: 1 / 1; /* 表と裏を同じ位置に重ね、高さは高いほうに合わせる */
  backface-visibility: hidden; /* 裏側を向いた面は描かない */
}

.flip-card__back {
  transform: rotateY(180deg); /* 裏の面は、最初から裏返しておく */
}

@media (prefers-reduced-motion: reduce) {
  .flip-card__inner {
    transition: none;
  }
}

それぞれの指定の役割は次のとおりです。

  • perspective は、見ている人と画面(z=0 の平面)の間の距離を決め、3D に置いた要素に遠近感を付ける指定です。値が小さいほど、変形が大きくなります。
  • transform-style: preserve-3d は、子の要素(表と裏の面)を、回転させる要素と同じ 3D の空間に置く指定です。
  • backface-visibility: hidden は、面が見ている人に対して裏側を向いたときに、その面を描かない指定です。

is-flipped を付けると、.flip-card__inner が 0 度から 180 度まで回ります。表の面は 90 度を過ぎると裏側を向くので描かれなくなり、最初から裏返してあった裏の面が、こちらを向いて見えるようになります。

「裏を見る」を押した後、transition を止めて、時間ごとの回転の角度を読み取りました。

text
0ms: 0 度
100ms: 39.7 度
150ms: 73.5 度
200ms: 103.7 度
250ms: 127 度
300ms: 144.4 度
400ms: 166.5 度
500ms: 177 度
600ms: 180 度

ease は、ゆっくり始まって急に速くなり、終わりに向かって遅くなる動きです。そのため、半分の 300ms で 144.4 度まで回り、残りの 300ms で 180 度に落ち着きます。表と裏が入れ替わる 90 度は、150ms と 200ms の間で通り過ぎました。

どれか1つが欠けると、こう見える

3つの指定のうち、backface-visibility と transform-style は、外すと見た目がはっきり崩れます。記事の CSS と、どちらか1つだけを変えた CSS で、押した直後・回転の途中・回り終わりのカードを撮りました。

3段の比較。1段目の記事の CSS は、「apple」から始まり、途中は台形の「apple」、回り終わると「りんご」。2段目の backface-visibility: visible は、押した直後から左右反転した「りんご」が見えている。3段目の transform-style: flat は、回り終わると左右反転した「apple」になる
記事の CSS と、一部を変えた CSS で、押した直後・回転の途中(150ms)・回り終わり(600ms)を撮ったもの。ピンクの枠は、見えてはいけない面が見えているところ

backface-visibility: visible にすると、裏返してある裏の面も描かれます。押した直後、まだ回り始める前(0ms)から、左右反転した「りんご」が表の面を隠してしまいました。2つの面は同じ位置にあり、この検証では、HTML で後にある裏の面のほうが上に描かれました。

transform-style: flat にすると、表と裏の面は回転させる要素の平面に押しつぶされます。押した直後(0ms)は「apple」が見えますが、回り終わっても「りんご」は出てきません。代わりに、左右反転した「apple」が見えます。MDN の説明では、平面化した子要素は、自身の 3D 空間に存在しなくなります。2つの面を押しつぶした1枚の平面を、そのまま裏から見ている形です。

裏の文章が長くても、高さが合うように重ねる

2つの面を重ねる方法として、面を position: absolute にして重ねる書き方もあります。ただし、position: absolute にした要素は通常のフローから外れ、ページの中に場所を取りません。カードの高さを決めておかないと、カードがつぶれます。

記事の CSS では、.flip-card__inner を display: grid にして、表と裏の面を grid-area: 1 / 1 で同じマスに置いています。こうすると、マスの高さは中身の多いほうの面に合います。裏の面に何行もの文章を入れたカードで、それぞれの高さを測りました。

text
grid で重ねる(記事の CSS): front 270px / back 270px / inner 270px
position: absolute で重ねる: front 44px / back 44px / inner 0px

grid で重ねた場合は、裏の文章に合わせてカードが 270px になり、短い表の面も同じ 270px に伸びました。position: absolute で重ねた場合は、回転させる要素の高さが 0px になり、面は上下の余白と枠線の 44px しかありませんでした。

表と裏を切り替える React のコンポーネント

コンポーネントは、表と裏の中身を props で受け取り、それぞれの面にボタンを置きます。

tsx
import { useRef, useState, type ReactNode } from 'react';
import { flushSync } from 'react-dom';

type Props = {
  front: ReactNode;
  back: ReactNode;
  frontButtonLabel?: string;
  backButtonLabel?: string;
};

export function FlipCard({
  front,
  back,
  frontButtonLabel = '裏を見る',
  backButtonLabel = '表に戻す',
}: Props) {
  const [flipped, setFlipped] = useState(false);
  const frontButton = useRef<HTMLButtonElement>(null);
  const backButton = useRef<HTMLButtonElement>(null);

  const flip = (next: boolean) => {
    // inert を付け替えた DOM を先に確定させてから、見えている面のボタンへフォーカスを移す
    flushSync(() => setFlipped(next));
    (next ? backButton : frontButton).current?.focus();
  };

  return (
    <div className="flip-card">
      <div className={flipped ? 'flip-card__inner is-flipped' : 'flip-card__inner'}>
        {/* 見えていない面は inert にして、クリック・フォーカス・読み上げの対象から外す */}
        <div className="flip-card__face flip-card__front" inert={flipped}>
          {front}
          <button ref={frontButton} type="button" onClick={() => flip(true)}>
            {frontButtonLabel}
          </button>
        </div>
        <div className="flip-card__face flip-card__back" inert={!flipped}>
          {back}
          <button ref={backButton} type="button" onClick={() => flip(false)}>
            {backButtonLabel}
          </button>
        </div>
      </div>
    </div>
  );
}

検証では、次のように単語カードとして使いました。

tsx
const front = <p className="word">apple</p>;
const back = <p className="word">りんご</p>;

<FlipCard front={front} back={back} />

回転そのものは CSS の transition に任せているので、React は is-flipped のクラスを付け外しするだけです。inert には真偽値をそのまま渡しています。React 19.3.1 と @types/react 19.3.0 の組み合わせでは、tsc の型チェックを通りました。ブラウザの DOM でも、true を渡した面にだけ inert 属性が付いていました。押す前は裏の面だけ、押した直後には表の面だけに付きました。

text
押す前: 0 度 / inert: front=false back=true
押した直後の inert: front=true back=false

見えていない面を inert にする

backface-visibility: hidden で消えるのは見た目だけです。消えている面のボタンや文字は、ページの中にそのまま残っています。

inert 属性を付けた要素とその中身は、クリックもフォーカスもできなくなり、タブ順序とアクセシビリティツリーから外れます。inert を付けない版と、記事の FlipCard で、表を見せているときのアクセシビリティツリーを比べました。読み上げの対象になっているボタンと文字だけを並べています(ボタンの文字は、ボタン自身とその中の文字の2つとして出ます)。

まず、inert を付けない版です。

text
表のときのツリー: button "裏を見る", button "表に戻す", StaticText "apple", StaticText "裏を見る", StaticText "りんご", StaticText "表に戻す"

次が、記事の FlipCard です。

text
表のときのツリー: button "裏を見る", StaticText "apple", StaticText "裏を見る"

inert を付けないと、見えていない裏の「りんご」と「表に戻す」も読み上げの対象に入ります。単語カードなら、答えを先に読み上げてしまうことになります。

Tab キーでも違いが出ます。表を見せたまま Tab を2回押すと、inert を付けない版では、見えていない「表に戻す」にフォーカスが入りました。

text
Tab 1回目: button "裏を見る"
Tab 2回目: button "表に戻す"

記事の FlipCard では、2回目の Tab でカードの外に出て、ページの中のどの要素にもフォーカスがない状態(document.activeElement が body)になりました。

text
Tab 1回目: button "裏を見る"
Tab 2回目: BODY

なお、inert は押した瞬間に付け替わります。回転の途中、90 度を過ぎるまでは表の面が見えていますが、その間も表の面には inert が付いているので、押せない状態です。

動きを減らす設定のときは、回さずに切り替える

OS で動きを減らす設定にしている人には、回転を見せずに表と裏を切り替えます。MDN の prefers-reduced-motion の説明によると、画面の動きは前庭運動障碍のある人に不快感を起こすことがあります。CSS の最後の @media (prefers-reduced-motion: reduce) で transition を外しているので、クラスが付いた時点で 180 度になります。Playwright で reducedMotion: 'reduce' を指定して開き、「裏を見る」を押した直後を調べました。

text
押した直後: アニメーション 0 個 / 180 度

transition が1つも作られず、押した直後にもう 180 度になっていました。

裏返したら、見えている面のボタンへフォーカスを移す

inert を付けると、もう1つ問題が出ます。キーボードで「裏を見る」を押すと、押したボタン自身が inert の中に入ってしまいます。フォーカスを移す処理を書かない版で、Enter を押した後のフォーカスを調べました。

text
Enter 直後のフォーカス: button "裏を見る"
次のフレームのフォーカス: BODY
続けて Enter: flip-card__inner is-flipped / フォーカス BODY

次のフレームでフォーカスが外れ、body に戻りました。続けて Enter を押しても、カードのクラスは is-flipped のままで、何も起きません。カードを表に戻すには、Tab で「表に戻す」を探し直す必要があります。

そこで FlipCard では、押した後に、見えるようになった面のボタンへ focus() でフォーカスを移しています。このとき、setFlipped の直後にそのまま focus() を呼んでも、うまくいきません。React は、イベントハンドラーの中のコードがすべて終わってから state の更新を処理するからです(公式ドキュメントの、state の更新をまとめて処理する説明)。focus() を呼んだ時点では、「表に戻す」はまだ inert の中にあります。flushSync を使わずに、setFlipped の直後に focus() を呼ぶ版も試しました。フォーカスを移す処理がない版とまったく同じで、次のフレームで BODY に外れました。

text
Enter 直後のフォーカス: button "裏を見る"
次のフレームのフォーカス: BODY

flushSync は、渡したコールバックの中の state の更新を、その場で DOM に反映させる関数です。公式ドキュメントは、パフォーマンスを下げることがあるので、めったに使わず最後の手段にするように書いています。ここでは、ボタンを押したときに1回だけ呼ぶ使い方です。flushSync で inert を付け替えた DOM を確定させてから focus() を呼ぶと、フォーカスは裏の面のボタンに移りました。

text
Enter 直後のフォーカス: button "表に戻す"
次のフレームのフォーカス: button "表に戻す"
続けて Enter: flip-card__inner / フォーカス button "裏を見る"

2回目の Enter では、クラスが flip-card__inner だけに戻ってカードは表になり、フォーカスは表の「裏を見る」に移りました。Enter を続けて押すだけで、表と裏を行き来できます。

ポイント

見えていない面は inert にして、読み上げとタブ順序から外します。そのうえで、flushSync で inert の付け替えを先に DOM に反映させてから、見えるようになった面のボタンに focus() を呼ぶと、キーボードだけで表と裏を行き来できます。

​Posted by 86