TeamsのWebサイトタブでiframeが画面より広がりIntersectionObserverで直す
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Microsoft TeamsのWebサイトタブは通知バーがタブの列を押し広げ、iframe自体が画面の表示領域より広くなることがある。幅基準で16:9の枠を縮小するコンポーネントは、この状態で右端・下端を画面外に切ってしまう。
結論
Microsoft Teamsの「Webサイト」タブに埋め込んだサイネージ盤面で、通知バーがタブの列を押し広げてiframe自体が画面の表示領域より広くなり(実測: iframe幅1290px・実際に見えているのは1082px)、iframe幅を基準に16:9の枠を縮小するコンポーネントが右端・下端を画面外に切っていた。IntersectionObserverで「ブラウザのビューポートに対して実際に見えている範囲」を測り、16:9の枠をその範囲に収める方式に変えた。
症状
キミテラスv2の盤面(/signage/[classToken])は、Microsoft Teamsの「Webサイト」タブにiframeで埋め込める。16:9の盤面を任意の画面サイズに収める FitStage コンポーネントが、iframeの幅(100vw)を基準に縮小率を決める作りだった。
const STAGE_W = 1920;
// ...
useEffect(() => {
const el = sizerRef.current;
if (!el) return;
const update = () => {
const width = el.getBoundingClientRect().width;
setScale(width > 0 ? width / STAGE_W : null);
};
// ...
}, []);
CSS側も .fitStageSizer の幅を min(100vw, calc(100vh * 16 / 9)) で決めていた。通常のブラウザや実機TVではこれで画面にぴったり収まるが、Teamsの「Webサイト」タブでこの盤面を開くと、時計や広告列など右側に配置した要素が画面外に切れて見えなくなっていた。
原因
Teamsの「Webサイト」タブのUIは、通知バー(white-space: nowrap で折り返さない要素)が表示されるとタブの列自体が押し広げられる。タブの列が広がれば、その中に置かれているiframeも連動して広がるため、iframe自体のサイズが、ユーザーに実際に見えている画面の表示領域より大きくなる。これはiframeの中のページからは検知も変更もできない、親フレーム側(Teams)のレイアウト変化だ。
/**
* Teams の「Webサイト」タブは盤面を iframe で埋め込むが、Teams 側の通知バー(`white-space:nowrap`)が列幅を押し広げ、
* iframe が画面より広くなる(2026-10-19 実測:iframe 1290px・見えているのは 1082px)。FitStage は iframe の幅
* (100vw)に合わせて縮めるので、右端の時計・広告列が画面外に切れていた。
*/
FitStage は 100vw(=iframe自体の幅)を基準に16:9の枠を縮小する。iframeが実際の表示領域より広くなっている状態では、100vw はその広いiframeの幅を指すため、FitStageは「見えている範囲よりも大きい枠」で16:9を計算してしまい、枠の右側(と、アスペクト比によっては下側)がTeamsの実際の表示領域からあふれて切れていた。
直す
IntersectionObserver で、画面全体を覆う透明な要素(sentinel)がブラウザのビューポートに対してどれだけ実際に交差しているかを測り、その交差矩形を「見えている範囲」として使うようにした。
/**
* 盤面ページが **iframe の中で一部しか見えていない**とき、見えている矩形を返す(FitStage が 16:9 の枠をそこへ縮める)。
* ...
* ## 返り値
* - `null` … ページ全体が見えている(普通のブラウザ・実機)。従来どおり CSS(100vw/100vh)に任せる。
* - 矩形 … ページ座標での見えている範囲。極端に小さいもの(タブ非表示中の 0 など)は測り損ないとして `null`。
*/
const TOLERANCE_PX = 1;
const MIN_VISIBLE_PX = 200;
export function visibleFitBox(
intersection: VisibleBox,
viewportWidth: number,
viewportHeight: number,
): VisibleBox | null {
const { x, y, width, height } = intersection;
if (width < MIN_VISIBLE_PX || height < MIN_VISIBLE_PX) {
return null;
}
const clipped = width < viewportWidth - TOLERANCE_PX || height < viewportHeight - TOLERANCE_PX;
if (!clipped) {
return null;
}
return {
x: Math.round(x),
y: Math.round(y),
width: Math.floor(width),
height: Math.floor(height),
};
}
FitStage は、画面全面に敷いた sentinel 要素を threshold を0〜1まで1%刻みで持つ IntersectionObserver で監視し、intersectionRect(実際に見えている矩形)を visibleFitBox に渡す。
const THRESHOLDS = Array.from({ length: 101 }, (_, i) => i / 100);
// ...
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[entries.length - 1];
if (!entry) return;
const r = entry.intersectionRect;
const next = visibleFitBox(
{ x: r.x, y: r.y, width: r.width, height: r.height },
window.innerWidth,
window.innerHeight,
);
setVisible((prev) => /* 変化が無ければ同じ参照を返して再レンダリングを抑える */ next);
},
{ threshold: THRESHOLDS },
);
見えている範囲が画面全体と一致していなければ、その矩形のサイズをCSS変数 --fit-box-w / --fit-box-h としてコンテナに渡す。CSS側はこの変数があればそれを優先し、無ければ従来の 100vw / 100vh にフォールバックする。
.fitStageSizer {
/* --fit-box-w/h は iframe で一部しか見えていないときだけ FitStage が入れる(見えている範囲。lib/signage/visible-fit.ts)。 */
width: min(var(--fit-box-w, 100vw), calc(var(--fit-box-h, 100vh) * 16 / 9));
aspect-ratio: 16 / 9;
container-type: inline-size;
overflow: hidden;
}
テストでは、実測値そのもの(iframe 1290px・見えている範囲 1082px)を含めて振る舞いを確認している。
it("Teams で右が切れたとき(iframe 1290px・見えている 1082px)は見えている範囲を返す", () => {
expect(visibleFitBox({ x: 0, y: 0, width: 1082.4, height: 760 }, 1290, 760)).toEqual({
x: 0,
y: 0,
width: 1082,
height: 760,
});
});
ページ全体が見えている通常のブラウザや実機TVでは、交差矩形が画面サイズとほぼ一致するため visibleFitBox は null を返し、CSSは --fit-box-w 無指定のまま従来の 100vw 基準に戻る。この経路は変更していない。
再発防止
具体的な再発防止策は根拠には書かれていないが、なぜこの事故に気づきにくかったかは構造から説明できる。FitStage が縮小率を決める根拠は、それまで「自分がどれだけの大きさで描画されているか(getBoundingClientRect().width)」だけで、「自分の外側にある親フレーム(Teams)の画面のうち、実際にユーザーに見えている範囲がどれだけか」という情報は一度も参照していなかった。iframeの中のページは、仕様上そもそも親フレームのレイアウトを直接知ることができない。
そのためTeams側の通知バーがタブの列を押し広げても、iframeの内側では「自分の幅が広がった」という変化しか観測できず、それが「実際に見えている範囲が狭くなった」ことを意味しているとは判定できなかった。IntersectionObserver の交差矩形は、iframeの内側からでも「ブラウザのビューポートに対して実際に見えている範囲」を取得できる数少ない手段であり、この矩形を基準にしたことで、親フレーム側のレイアウト変化をiframeの外から教えてもらわずに検知できるようになった。
よくある質問
Q1なぜTeamsのタブでiframeが画面より広くなるのですか?
Teamsの「Webサイト」タブのUIは、通知バー(white-space: nowrapで折り返さない要素)が表示されると、そのタブの列自体が押し広げられる。タブの列が広がればその中に置かれたiframeも連動して広がるため、iframe自体のサイズがユーザーに実際に見えている画面の表示領域より大きくなる。実測ではiframe幅1290pxに対し、実際に見えているのは1082pxだった。
Q2FitStageはなぜ右端を切ってしまったのですか?
FitStageはiframeの幅(100vw)を基準に16:9の枠を縮小する作りだった。iframeそのものが画面の表示領域より広くなると、100vwはその広いiframeの幅を指すため、実際に見えている範囲より大きい枠で計算してしまい、時計や広告列など右側に配置された要素が画面外(Teamsの表示領域の外)に切れていた。
Q3直し方はどういう仕組みですか?
IntersectionObserverで、画面全体を覆う透明な要素(sentinel)がブラウザのビューポートに対してどれだけ交差しているかを1%刻みのthresholdで測り、実際に見えている矩形を求める。見えている範囲が画面全体より小さいときだけ、その矩形のサイズをCSS変数--fit-box-w/--fit-box-hに渡し、16:9の枠をその範囲に収める。ページ全体が見えている通常のブラウザや実機TVではnullが返り、従来どおりiframe幅=表示幅のままになる。
Q4通常のブラウザや実機TVの表示に影響しますか?
影響しない。visibleFitBoxは見えている範囲が画面サイズとほぼ一致している(1px未満の差は丸め誤差として同一視する)ときはnullを返す仕様で、CSS側もvar(--fit-box-w, 100vw)のように--fit-box-wが無指定のときは100vwへフォールバックするため、Teamsのタブ以外の表示では以前と同じ計算になる。
確認した環境
- Next.js ^16.3.8(apps/web/package.json)
- 2026-10-19 に本番で修正(commit d2b998a・#1521)
この記事の根拠
- TypeScriptファイル 1〜45行目コミット d2b998a
- TypeScriptファイル 1〜99行目コミット d2b998a
- CSSファイル 4384〜4430行目コミット d2b998a
- TypeScriptファイル 1〜29行目コミット d2b998a
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。