07Anchor Placement That Flips Direction After Measuring Remaining Space
The remaining screen space above and below the input is each measured as a value, the larger one is chosen, and the panel only flips to open above the input when the space-below value is smaller than the panel's height.
Best forInputs pinned near the bottom, toolbars above a mobile keyboard
EMOJI_PANEL_FLIP_MARGIN_PXmeasureAndPlacemovePosition

<main class="stage">
<div class="af is-demo" id="af">
<div class="af__viewport" id="afViewport">
<div class="af__panel af__panel--below" id="afPanel" role="dialog" aria-label="Emoji panel" hidden>
<button class="af__pick" type="button">😀</button>
<button class="af__pick" type="button">👍</button>
<button class="af__pick" type="button">❤️</button>
<button class="af__pick" type="button">🎉</button>
<button class="af__pick" type="button">🔥</button>
<button class="af__pick" type="button">👀</button>
</div>
<button class="af__trigger af__trigger--low" id="afTrigger" type="button" aria-haspopup="true" aria-expanded="false">😀 Add</button>
</div>
<p class="af__sr" role="status" aria-live="polite" id="afLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html measureAndPlace() 가 뷰포트·입력창의 실제 렌더 좌표(getBoundingClientRect)와
// 패널의 실제 렌더 높이(offsetHeight)를 재서, 아래쪽 남은 공간이 패널 높이보다 작을 때만 위로 연다.
@include stage($stage-orange);
.af { position: relative; width: 100%; height: 100%; }
.af__viewport { position: absolute; inset: 0; }
.af__trigger {
position: absolute; left: 50%; transform: translateX(-50%);
padding: $sp-2 $sp-3; border: none; border-radius: $r-pill;
background: rgba($subject-cream, .95); color: $subject-ink; font-size: 13px; font-weight: 600;
cursor: pointer; box-shadow: $shadow-raised;
}
.af__trigger--low { bottom: 14px; }
.af__trigger--high { top: 14px; }
.af__panel {
position: absolute; left: 50%; display: flex; gap: $sp-1; padding: $sp-2; border-radius: $r-card;
background: rgba($subject-cream, .97); box-shadow: $shadow-raised;
transform: translate(-50%, var(--ty, 0)) scale(.92); opacity: 0; pointer-events: none;
transition: transform $dur-base $ease-spring, opacity $dur-quick $ease-out;
}
.af__panel--below { --ty: -8px; }
.af__panel--above { --ty: 8px; }
.af__panel--open { transform: translate(-50%, 0) scale(1); opacity: 1; pointer-events: auto; }
.af__pick {
width: 30px; height: 30px; border: none; border-radius: $r-sm; background: transparent;
font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.af__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); margin: -1px; padding: 0; border: 0; }
@media (max-width: 340px) {
.af__panel { gap: $sp-1; padding: $sp-1; }
.af__pick { width: 24px; height: 24px; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.af__panel { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.af {
position: relative;
width: 100%;
height: 100%;
}
.af__viewport {
position: absolute;
inset: 0;
}
.af__trigger {
position: absolute;
left: 50%;
transform: translateX(-50%);
padding: 8px 12px;
border: none;
border-radius: 999px;
background: rgba(255, 247, 230, 0.95);
color: #17141a;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.af__trigger--low {
bottom: 14px;
}
.af__trigger--high {
top: 14px;
}
.af__panel {
position: absolute;
left: 50%;
display: flex;
gap: 4px;
padding: 8px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.97);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(-50%, var(--ty, 0)) scale(0.92);
opacity: 0;
pointer-events: none;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms ease-out;
}
.af__panel--below {
--ty: -8px;
}
.af__panel--above {
--ty: 8px;
}
.af__panel--open {
transform: translate(-50%, 0) scale(1);
opacity: 1;
pointer-events: auto;
}
.af__pick {
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.af__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
margin: -1px;
padding: 0;
border: 0;
}
@media (max-width: 340px) {
.af__panel {
gap: 4px;
padding: 4px;
}
.af__pick {
width: 24px;
height: 24px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.af__panel {
transition: none;
}
}(function () {
var EMOJI_PANEL_FLIP_MARGIN_PX = 8;
var root = document.getElementById('af');
var viewport = document.getElementById('afViewport');
var trigger = document.getElementById('afTrigger');
var panel = document.getElementById('afPanel');
var live = document.getElementById('afLive');
var synthetic = false;
function human() { if (synthetic) return; if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
// 계산 겹: 입력창 위·아래로 남은 화면 공간 값을 실측(getBoundingClientRect)해 비교한다. 패널
// 높이도 짐작하지 않고 화면 밖(visibility:hidden)에서 한 번 펼쳐 실제 렌더 높이를 잰다. 아래쪽
// 공간 값이 패널 높이보다 여유값(EMOJI_PANEL_FLIP_MARGIN_PX)만큼도 안 남을 때만 위로 연다.
function measureAndPlace() {
var vpRect = viewport.getBoundingClientRect();
var trRect = trigger.getBoundingClientRect();
var wasHidden = panel.hidden;
panel.style.visibility = 'hidden';
panel.hidden = false;
var panelH = panel.offsetHeight;
panel.hidden = wasHidden;
panel.style.visibility = '';
var spaceBelow = vpRect.bottom - trRect.bottom;
var spaceAbove = trRect.top - vpRect.top;
var openAbove = (spaceBelow - EMOJI_PANEL_FLIP_MARGIN_PX) < panelH && spaceAbove > spaceBelow;
panel.classList.toggle('af__panel--above', openAbove);
panel.classList.toggle('af__panel--below', !openAbove);
if (openAbove) {
panel.style.bottom = (vpRect.bottom - trRect.top + EMOJI_PANEL_FLIP_MARGIN_PX) + 'px';
panel.style.top = 'auto';
} else {
panel.style.top = (trRect.bottom - vpRect.top + EMOJI_PANEL_FLIP_MARGIN_PX) + 'px';
panel.style.bottom = 'auto';
}
panel.style.left = (trRect.left + trRect.width / 2 - vpRect.left) + 'px';
live.textContent = 'Space below ' + Math.round(spaceBelow) + 'px · above ' + Math.round(spaceAbove) + 'px, ' + (openAbove ? 'opened upward' : 'opened downward');
return openAbove;
}
function openPanel() {
measureAndPlace();
panel.hidden = false;
requestAnimationFrame(function () { panel.classList.add('af__panel--open'); });
trigger.setAttribute('aria-expanded', 'true');
}
function closePanel() {
panel.classList.remove('af__panel--open');
trigger.setAttribute('aria-expanded', 'false');
window.setTimeout(function () { if (!panel.classList.contains('af__panel--open')) panel.hidden = true; }, 220);
}
trigger.addEventListener('click', function () {
human();
if (panel.hidden) openPanel(); else closePanel();
});
window.addEventListener('resize', function () {
if (!panel.hidden) measureAndPlace();
});
function movePosition(low) {
trigger.classList.toggle('af__trigger--low', low);
trigger.classList.toggle('af__trigger--high', !low);
if (!panel.hidden) measureAndPlace();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var lowState = true, loopTimer = 0;
function runStep() {
if (!root.classList.contains('is-demo')) return;
movePosition(lowState);
lowState = !lowState;
synthetic = true; trigger.dispatchEvent(new MouseEvent('click', { bubbles: true })); synthetic = false;
window.setTimeout(function () {
synthetic = true; trigger.dispatchEvent(new MouseEvent('click', { bubbles: true })); synthetic = false;
loopTimer = window.setTimeout(runStep, 480);
}, 900);
}
if (!reduce) { loopTimer = window.setTimeout(runStep, 420); }
else { root.classList.remove('is-demo'); }
})();// EmojiPickerAnchorPositionFlip — vanilla 판(run/402/demo/07-.../index.html) 의 React 포트.
// 계산 겹: measureAndPlace() 가 뷰포트·입력창의 실제 렌더 좌표(getBoundingClientRect)와 패널의
// 실제 렌더 높이(offsetHeight)를 재서, 아래쪽 남은 공간이 패널 높이보다 작을 때만 위로 연다.
import { useEffect, useRef, useState } from "react";
import styles from "./EmojiPickerAnchorPositionFlip.module.scss";
type Box<T> = { current: T };
const EMOJI_PANEL_FLIP_MARGIN_PX = 8;
const PICKS = ["\u{1F600}", "\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F389}", "\u{1F525}", "\u{1F440}"];
export default function EmojiPickerAnchorPositionFlip() {
const [isDemo, setIsDemo] = useState(true);
const [open, setOpen] = useState(false);
const [above, setAbove] = useState(false);
const [low, setLow] = useState(true);
const [panelStyle, setPanelStyle] = useState<{ top?: string; bottom?: string; left?: string }>({});
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const synthRef = useRef(false) as unknown as Box<boolean>;
const openRef = useRef(false) as unknown as Box<boolean>;
const lowRef = useRef(true) as unknown as Box<boolean>;
const liveRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const viewportRef = useRef(null) as unknown as Box<HTMLDivElement>;
const triggerRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const panelRef = useRef(null) as unknown as Box<HTMLDivElement>;
const loopTimerRef = useRef(0) as unknown as Box<number>;
function human() {
if (synthRef.current) return;
if (isDemoRef.current) { isDemoRef.current = false; setIsDemo(false); }
}
function measureAndPlace(): boolean {
const vp = viewportRef.current, tr = triggerRef.current, pn = panelRef.current;
if (!vp || !tr || !pn) return false;
const vpRect = vp.getBoundingClientRect();
const trRect = tr.getBoundingClientRect();
const wasHidden = pn.hidden;
pn.style.visibility = "hidden";
pn.hidden = false;
const panelH = pn.offsetHeight;
pn.hidden = wasHidden;
pn.style.visibility = "";
const spaceBelow = vpRect.bottom - trRect.bottom;
const spaceAbove = trRect.top - vpRect.top;
const openAbove = (spaceBelow - EMOJI_PANEL_FLIP_MARGIN_PX) < panelH && spaceAbove > spaceBelow;
setAbove(openAbove);
const left = (trRect.left + trRect.width / 2 - vpRect.left) + "px";
if (openAbove) {
setPanelStyle({ bottom: (vpRect.bottom - trRect.top + EMOJI_PANEL_FLIP_MARGIN_PX) + "px", top: "auto", left: left });
} else {
setPanelStyle({ top: (trRect.bottom - vpRect.top + EMOJI_PANEL_FLIP_MARGIN_PX) + "px", bottom: "auto", left: left });
}
if (liveRef.current) {
liveRef.current.textContent = "남은 공간 아래 " + Math.round(spaceBelow) + "px · 위 " + Math.round(spaceAbove) + "px, " + (openAbove ? "위로 열림" : "아래로 열림");
}
return openAbove;
}
function togglePanel() {
human();
if (!openRef.current) {
measureAndPlace();
openRef.current = true;
setOpen(true);
} else {
openRef.current = false;
setOpen(false);
}
}
function movePosition(nextLow: boolean) {
lowRef.current = nextLow;
setLow(nextLow);
if (openRef.current) window.setTimeout(measureAndPlace, 0);
}
useEffect(() => {
function onResize() { if (openRef.current) measureAndPlace(); }
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let lowState = true;
function runStep() {
if (!isDemoRef.current) return;
movePosition(lowState);
lowState = !lowState;
synthRef.current = true; togglePanel(); synthRef.current = false;
window.setTimeout(() => {
synthRef.current = true; togglePanel(); synthRef.current = false;
loopTimerRef.current = window.setTimeout(runStep, 480);
}, 900);
}
if (!reduce) { loopTimerRef.current = window.setTimeout(runStep, 420); }
else { isDemoRef.current = false; setIsDemo(false); }
return () => { window.removeEventListener("resize", onResize); window.clearTimeout(loopTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.af + " " + (isDemo ? styles.isDemo : "")}>
<div className={styles.viewport} ref={viewportRef}>
<div
className={
styles.panel + " " +
(above ? styles.panelAbove : styles.panelBelow) + " " +
(open ? styles.panelOpen : "")
}
role="dialog"
aria-label="이모지 패널"
hidden={!open}
ref={panelRef}
style={panelStyle}
>
{PICKS.map((p, i) => (
<button key={i} type="button" className={styles.pick}>{p}</button>
))}
</div>
<button
className={styles.trigger + " " + (low ? styles.triggerLow : styles.triggerHigh)}
type="button"
aria-haspopup="true"
aria-expanded={open}
ref={triggerRef}
onClick={togglePanel}
>
{"\u{1F600} 추가"}
</button>
</div>
<p className={styles.sr} role="status" aria-live="polite" ref={liveRef} />
</div>
</main>
);
}07 Anchor Placement That Flips Direction After Measuring Remaining Space — Measured area 27.115% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · Emoji Picker: 9 Patterns by One Value
The post this demo lives in
Emoji Picker: 9 Patterns by One ValueNine emoji picker patterns: The panel flips upward only when the space-below value is smaller than its height, variants See all demos in this post
If you like this effect - similar demos
Inputs03 Windows spectrum — a color picker that splits a 2D position into saturation and lightness9 Color Picker Design Patterns (Copy-Paste)
Inputs03 Inline hint that flips while you type9 Form Validation UI Patterns — As You Type
Inputs05 Vertical drag inertia — a stepper that changes with drag distance and keeps advancing a few steps after releaseNumber Stepper UI: 9 Interaction Patterns