Next.js v15 + shadcn/ui v2:Switchコンポーネントが原因でoverflowが壊れた話

はじめに

最近Next.js v15、Tailwind CSS v4、shadcn/ui v2.3を使ったプロジェクトで、コンポーネントの実装中に不思議なレイアウトの問題に直面しました。
この記事では、shadcn/uiのSwitchコンポーネントを使用した際に起きたスクロール関連の問題と、その原因究明から解決までのプロセスを、備忘も兼ねて残しておこうと思います。

発生した問題

フォーム画面内でshadcn/uiのSwitchコンポーネントを使用したところ、フォームコンテナに設定したoverflow-y-scrollが正常に機能せず、コンテンツの表示領域外のページ全体にもスクロールバーが出現してしまいました。

フォームコンテナだけでなく、ページ全体にもスクロールが発生してしまっている

環境情報

問題が発生した環境は以下の通りです:
- Next.js @15.2.3
- shadcn/ui @2.3.0
- TailwindCSS @^4.0.14

問題を再現するためのシンプルなコード例
export default function MyForm() {
  const [state, formAction] = useActionState(action, {  ...initialActionState } as ActionState);
  ...
  ...

  return (
    <form
      action={formAction}
      className="flex flex-col w-225 gap-10 h-page-screen py-12"
    >
      ...
      ...
      ...

      <div className="flex flex-col gap-4">
        <h2 className="text-xl/7 font-semibold">スイッチ</h2>
        <Switch />
      </div>
    </form>
  );
}

Switchコンポーネントpnpm dlx shadcn-ui@latest add switch で追加したものです。

原因の調査

問題解決のためDevToolsで要素を調査すると、以下のことが判明しました:

  1. Switchコンポーネント内のhidden inputがposition: absoluteスタイルを持っている
  2. さらに、この要素が画面の非常に下の位置(ページ最下部付近)に配置されている

hidden inputがabsoluteによってコンテナ外のページ最下部に飛ばされている

shadcn/uiのSwitchコンポーネントのコードを確認したところ、シンプルにradix-uiのSwitchコンポーネントをラップしてスタイルを当てているだけでした。
問題は大本の@radix-ui/react-switchの内部実装に関連していました。
Radixはhidden inputを使用して実際のチェックボックス状態を管理していますが、このinput要素がposition: absoluteで配置され、親要素のpositionが設定されていないため、ページ全体を基準として配置されていました。

実際にissueを探してみると、同様の問題が報告されています。 github.com

解決策

この問題を解決するために、Switchコンポーネントを含む親要素にposition: relativeを設定することで、hidden inputの配置基準を変更しました。

export default function EventForm({ event }: { event?: EventDetail }) {
  ...
  ...

  return (
    <form
      action={formAction}
      className="flex flex-col w-225 gap-10 h-page-screen py-12"
    >
      ...
      ...
      ...

      <div className="flex flex-col gap-4">
        <h2 className="text-xl/7 font-semibold">スイッチ</h2>
+       <div className="relative">
          <Switch />
+       </div>
      </div>
    </form>
  );
}

あるいは、もちろん別途使い回せるラッパーコンポーネントを作成したり、shadcnのSwitch自体を修正する方法もあります。

function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
  return (
+   <div className="relative">
      <SwitchPrimitive.Root
        data-slot="switch"
        className={cn(
          "peer data-[state=checked]:bg-slate-900 data-[state=unchecked]:bg-slate-200 focus-visible:border-slate-950 dark:data-[state=unchecked]:bg-slate-200/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-slate-200 border-transparent shadow-xs transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:data-[state=checked]:bg-slate-50 dark:data-[state=unchecked]:bg-slate-800 dark:focus-visible:border-slate-300 dark:dark:data-[state=unchecked]:bg-slate-800/80 dark:border-slate-800",
          className
        )}
        {...props}
      >
        <SwitchPrimitive.Thumb
          data-slot="switch-thumb"
          className={cn(
            "bg-white dark:data-[state=unchecked]:bg-slate-950 dark:data-[state=checked]:bg-slate-50 pointer-events-none block size-6 rounded-full transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:bg-slate-950 dark:dark:data-[state=unchecked]:bg-slate-50 dark:dark:data-[state=checked]:bg-slate-900"
          )}
        />
      </SwitchPrimitive.Root>
+   </div>
  );
}

問題修正後、正常にスクロールできるようになったフォーム画面

ちなみに、これならSwitchだけでなくCheckboxなどのinputでも同様のことが起こるのでは?とふと思ったので調べたら案の定issueや解決記事がありました。
全く同じ問題ですね!
github.com zenn.dev

まとめ

今回の問題は、次の点から発生していました:
- shadcn/uiのSwitchコンポーネント内部で使用されているhidden inputがposition: absoluteで配置されている。
- この要素に対する親要素にposition: relativeが設定されていないため、ページ全体を基準に配置されてしまう。
- 結果として、要素がページ最下部に配置され、overflow-scrollの挙動が不自然になる。

この問題は単純に親要素にposition: relativeを追加するだけで解決できます。
UIライブラリを使用する際は、内部実装の詳細まで理解することで、予期せぬレイアウト問題を効率的に解決できますね。

最後に

IOでは常にエンジニアを積極採用中です!

少しでも興味を持っていただける方がいましたら、是非カジュアル面談からでも是非ご応募ください! よろしくお願いします!

herp.careers herp.careers