07남은 공간 값을 계산해 여는 방향이 바뀌는 앵커 배치
입력창 위·아래로 남은 화면 공간 값을 각각 재서 더 넓은 쪽을 고르고, 아래쪽 공간 값이 패널 높이보다 작을 때만 패널이 입력창 위로 열리도록 자리를 바꾼다.
쓰임화면 아래쪽 입력창, 모바일 키보드 위 도구줄
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="이모지 패널" 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">😀 추가</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 = '남은 공간 아래 ' + Math.round(spaceBelow) + 'px · 위 ' + Math.round(spaceAbove) + 'px, ' + (openAbove ? '위로 열림' : '아래로 열림');
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 남은 공간 값을 계산해 여는 방향이 바뀌는 앵커 배치 — 측정 누적면적 27.115% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이모지 검색기 9가지 — 값 하나로 여닫는 패턴
이 데모가 속한 글
이모지 검색기 9가지 — 값 하나로 여닫는 패턴이모지 검색기 아홉 가지를 나란히 놓고 보면, 입력창 아래 남은 공간 값이 패널 높이보다 작을 때만 패널이 위로 열리는 자리로 바뀌고, 누르고 있는 시간 값이 정해둔 값을 넘을 때만 같은 이모지의 변형이 부채꼴로 펼 이 글의 데모 전부 보기


