Ayden's journal

모달과 팝오버, 그리고 앵커 포지셔닝

모달과 팝오버를 칼로 베듯 명확히 구분해본 적은 없었다. 그저 화면 한가운데에 떠서 다른 작업을 막으면 모달, 특정 버튼을 눌렀을 때 앵커를 기준으로 나타나면 팝오버라는 정도로 느슨하게 생각해왔다. 하지만 두 개념은 단순한 위치나 동작의 차이를 넘어, 그 목적과 사용 방식에서 뚜렷한 차이를 지닌다. 이를 제대로 이해하면 사용자 경험을 보다 세밀하게 설계할 수 있다.

아래의 표에서 조금 더 체계적으로 정리해두었지만, 모달은 차단적 인터페이스다. 사용자가 모달을 닫기 전까지는 배경의 다른 요소와 상호작용할 수 없다. 이러한 차단성은 주로 중요한 작업을 처리하거나 사용자로부터 명확한 결정을 요구할 때 사용된다. 예를 들어, 삭제 확인 창이나 로그인 폼이 대표적이다.

반면, 팝오버는 비차단적 인터페이스로, 배경과의 상호작용을 유지하면서 추가적인 정보를 제공하거나 간단한 동작을 수행하도록 돕는다. 툴팁, 드롭다운 메뉴, 입력 필드의 유효성 검사 메시지가 팝오버의 전형적인 사례다.

따라서 사용자가 한 가지 작업에만 집중해야 한다면 모달을 사용하는 것이 적합하다. 반면에 다른 작업 흐름을 방해하지 않고 부가적인 정보를 제공하거나, 추가적인 옵션을 제공하고자 하는 경우에는 팝오버가 더 적합하다.

 

dialog tag

dialog는 이러한 모달과 팝오버를 구현하기 위한 시맨틱 태그이다. 앞서 살펴보았던 것처럼 모달과 팝오버는 배경과의 상호작용을 어떻게 처리할 것인가에 따라 나뉘게 되는데, dialog는 각각을 서로 다른 메소드 ─ showModal()과 show() ─ 를 통해 구현하게 된다.

export default function Page() {
  const dialogRef = useRef<HTMLDialogElement>(null)

  return (
    <>
      <button type="button" onClick={() => dialogRef.current?.showModal()}>모달 열기</button>
      <button type="button" onClick={() => dialogRef.current?.show()}>팝오버 열기</button>
    
      <Dialog ref={dialogRef}>
        <Content close={() => dialogRef.current?.close()}>
          this is a content inside dialog
        </Content>
      </Dialog>
    </>
  )
}

const Dialog = forwardRef<HTMLDialogElement, { children: ReactNode }>(({ children }, ref) => {
  return (
    <dialog ref={ref}>{children}</dialog>
  );
});

function Content({ close, children }: { close: () => void , children: ReactNode }) {
  return (
    <div>
    {children}
      <button type="button" onClick={() => close()}>닫기</button>
    </div>
  )
}

 

팝오버로 열 때는 일반적인 맥락에 따라 dialog 태그가 랜더링된다. 그러나 모달로 여는 경우에는 top-layer라는 전혀 다른 레이어에서 랜더링된다. 덕분에 z-index 고민할 필요 없이 사용할 수 있다는 장점이 있다. 또한 모달로 열 때는 ::backdrop이라는 가상 엘리먼트가 존재하는데, 이를 통해 dialog 태그는 다른 요소와 상호작용하지 못하도록 유저 인터페이스를 차단하게 된다.

 

그러나 dialog 태그를 사용하는 방법에는 몇 가지 아쉬운 점이 있다. 우선 dialog를 닫기 위해서는 close() 메소드를 호출해야 하며, 따라서 이를 처리하는 로직을 어딘가에 배치해야 한다. 또한 팝오버로 띄우는 경우에는 top-layer 가 아니기 때문에 여전히 z-index를 고민해야 한다.

 

command and commandFor pattern

이전까지만 해도 dialog 태그를 열기 위해서는 showModal() 메서드를 호출해야 했다. 그러나 지난 3월에 새롭게 발표된 command 및 commandFor 속성을 통해 버튼이 dialog와 PopoverAPI 등에 대해 선언적으로 동작을 제어할 수 있게 되었다. commandFor는 label-input의 for 또는 Popover API의 popoverTarget과 같이 동작이 적용될 태그를 지정하는 요소이다. command는 대화형 요소의 다양한 API에 매핑되는데, 각각의 값과 그에 따라 매핑되는 API의 목록은 아래와 같다.

  • show-popover: el.showPopover()에 매핑
  • hide-popover: el.hidePopover()에 매핑
  • toggle-popover: el.togglePopover()에 매핑
  • show-modal: dialogEl.showModal()에 매핑
  • close: dialogEl.close()에 매핑

command와 commandFor를 사용하여 위의 예시를 다시 작성하면 아래와 같다. 아쉽게도 dialog를 팝오버로 띄우는 내장 명령어는 존재하지 않아 예시 코드에서 빠졌다. 예시 코드를 보면 별다른 자바스크립트 코드 없이 html attr 만으로 아주 간단하게 모달을 여닫고 있음을 알 수 있다.

export default function Page() {
  const dialogId = "test-dialog-id";

  return (
    <>
      <button type="button" command="show-modal" commandFor={dialogId}>모달 열기</button>
    
      <Dialog id={dialogId}>
        <Content dialogId={dialogId}>
          this is a content inside dialog
        </Content>
      </Dialog>
    </>
  )
}

const Dialog = ({ children, id }) => {
  return (
    <dialog id={id}>{children}</dialog>
  );
};

function Content({ dialogId, children }: { close: string, children: ReactNode }) {
  return (
    <div>
    {children}
      <button type="button" command="close" commandFor={dialogId}>닫기</button>
    </div>
  )
}

 

custom command

아쉽게도 dialog를 팝오버로 띄우는 명령어는 존재하지 않지만, 내장 명령어 외에도 -- 접두사를 사용하여 커스텀 명령어를 정의할 수 있다. 커스텀 명령어는 내장 명령어와 마찬가지로 타겟 요소에 command 이벤트를 전달하지만, 그 외에는 전부 수동으로 처리해주어야 한다. 아직 리액트는 dialog 태그에 onCommand와 같은 props를 제공하지 않으므로 useEffect를 통해 커스텀 명령어를 구현하였다.

export default function Page() {
  const dialogId = "test-dialog-id";

  useEffect(() => {
    const dialog = document.getElementById(dialogId) as HTMLDialogElement;

    dialog.addEventListener("command", (event) => {
      if ( event.command == "--dialog-popover-show" ) {
        dialog.show()
      }
    });
  }, []);

  return (
    <>
      <button type="button" command="show-modal" commandFor={dialogId}>모달 열기</button>
      <button type="button" command="--dialog-popover-show" commandFor={dialogId}>팝오버 열기</button>
    
      <Dialog id={dialogId}>
        <Content dialogId={dialogId}>
          this is a content inside dialog
        </Content>
      </Dialog>
    </>
  )
}
  return (
    <>
      <button type="button" command="show-modal" commandFor={dialogId}>모달 열기</button>
    
      <Dialog id={dialogId}>
        <Content dialogId={dialogId}>
          this is a content inside dialog
        </Content>
      </Dialog>
    </>
  )
}

 

closedby

기존 dialog의 한 가지 불편한 점이라면 대화상자 외부를 클릭할 때 저절로 닫히지 않아서 자바스크립트를 통해 이를 처리해주어야 했다는 것이다. 그러나 closedby의 도입을 통해 아주 간단히 외부 클릭 닫기를 처리할 수 있게 되었다.

  • any : dialog 외부 혹은 ESC 키를 누르면 닫힘
  • closerquest(기본값) : ESC 키(또는 기타 닫기 트리거)를 누를 때만 닫힘
  • none : dialog 외부 혹은 ESC 키를 눌러도 닫히지 않음
const Dialog = ({ children, id, closedby = "closerequest" }: { children: ReactNode, id: string, closedby: "any" | "closerequest" | "none" }) => {
  return (
    <dialog id={id} closedby={closedby}>{children}</dialog>
  );
};

 

 

Popover API

command와 closedby의 도입으로 dialog 태그가 전에 없이 강력해졌지만, 여전히 몇 가지 아쉬운 부분이 존재한다. Popover API는 이런 아쉬운 부분을 시원하게 긁어주는 역할을 한다. dialog 태그의 팝오버와 달리 Popover API를 사용하면 어떤 태그라도 top-layer에서 랜더링되며, 따로 로직을 구현하지 않아도 외부 클릭 닫기(light-dismiss)를 지원한다.

Popover API는 매우 간단하게 사용할 수 있다. 먼저 팝오버가 될 요소에 popover 속성을 설정하고 고유한 id를 추가한다. 그리고 버튼의 popoverTarget를 팝오버 요소의 id 값으로 설정하면 끝이다. popoverTargetAction 속성은 버튼을 클릭했을 때 팝오버가 어떤 동작을 할지 정의하는 역할을 한다. 기본값은 "toggle"로, 팝오버가 열려 있으면 닫고, 닫혀 있으면 여는 방식이다. "show"나 "hide"를 지정하면 명시적으로 열거나 닫는 제어도 가능하다.

export default function Page() {
 return (
  <>
    <button popoverTarget="popover" popoverTargetAction="show">팝오버 오픈</button>

    <div popover="auto" id="popover">
      <p>asdfsadf팝오버 내용</p>
    </div>
  </>
 )
}

 

css 적으로 본다면 dialog와 달리 Popover API를 사용하면 :popover-open 가상 클래스로 열린 상태의 스타일을 지정할 수가 있다.

 

popover attr

popover 속성은 두 가지일 수도 있고 세 가지일 수도 있다. 이게 뭔 슈뢰딩거의 고양이 같은 소리인가 싶겠지만 일단 진행해보겠다. 일반적으로 popover에는 auto와 manual 속성을 지정하게 된다. popover="auto"는 상위 요소 팝오버를 제외한 다른 자동 팝오버를 강제로 닫으며, 외부 클릭 닫기를 지원한다. 반면 popover="manual"를 설정하면 수동 팝오버라는 다른 유형의 팝오버가 생성된다. 이러한 요소는 다른 요소 유형을 강제로 닫지 않으며 외부 클릭 닫기를 지원하지 않고, 타이머 또는 명시적 닫기 작업을 통해 닫아야 한다.

그리고 popover="hint"가 있다. 이 속성은 popover="auto"와 마찬가지로 조용히 닫기를 지원한다. 그러나 popover="auto"는 열릴 때 다른 모든 auto 및 hint 팝오버를 닫아 한 번에 하나의 팝오버만 활성 상태가 되도록 하는 반면, popover="hint"는 다른 hint 팝오버만 강제로 숨겨 메뉴와 도움말이 열려 있고 공존할 수 있도록 한다.

 

출처 : https://youtu.be/VTCIStB6y8s?si=dfXcFjs2Nsi0plia

 

stack

popover="auto"는 기본적으로 하나의 popover가 열리면 다른 popover를 닫는 동작을 한다. 이는 사용자가 화면에서 여러 popover가 동시에 열려 혼란스러워지는 상황을 방지하기 위함이다. 이때 사용되는 개념이 바로 auto stack이다. 같은 auto stack에 속한 popover들은 서로 경쟁 관계로 간주되며, 하나가 열리면 나머지는 자동으로 닫힌다. 하지만 이 스택은 단순히 "하나만 열 수 있다"는 제한만을 의미하지 않는다.

auto stack은 중첩(nested) popover를 유연하게 허용한다. 즉, 하나의 popover 내부에서 또 다른 popover를 열면, 이 둘은 같은 stack에 속하면서도 서로를 닫지 않는 예외적인 관계가 된다. 이러한 중첩 구조 덕분에 메뉴 → 서브메뉴 → 팝오버 같은 복잡한 UI를 자연스럽게 구성할 수 있다. 또 다른 popover가 외부에서 열릴 경우엔 기존 스택이 종료되며 중첩된 popover까지 함께 닫힌다. 이처럼 auto stack은 단일성과 중첩을 동시에 지원하며, 직관적인 사용자 경험을 제공한다.

 

popover="hint"는 툴팁과 같은 힌트용 UI에 적합하도록 설계된 popover 유형으로, 일반적인 auto popover와는 다른 특수한 중첩 동작을 가진다. 가장 큰 특징은 독립적인 힌트 스택(hint stack)이 존재한다는 점이다. hint popover는 기본적으로 자동으로 닫히며, 사용자의 포커스나 호버 상태에 따라 간단히 열리고 사라지도록 설계되어 있다. 하지만 hint popover가 auto popover 내부에 중첩되었을 경우에는 일반 스택과는 달리 auto 스택에 참여하게 된다.

이러한 구조 덕분에 hint popover는 상위 auto popover의 문맥(context) 안에 자연스럽게 녹아든다. 예를 들어 메뉴(popover="auto") 안에 버튼 하나가 있고, 그 위에 툴팁(popover="hint")이 떠 있는 경우, 사용자가 툴팁을 띄웠다고 해서 메뉴가 닫히지 않는다. 이는 hint가 auto 스택에 소속되어 있기 때문에, 별도로 auto popover를 방해하지 않고 문맥 그룹을 유지하기 때문이다. 결과적으로 힌트는 유동적으로 표시되되, 사용자의 흐름을 방해하지 않는 직관적인 동작을 제공하게 된다.

 

interestTarget

popover="hint"와 관련하여 흥미롭게 살펴볼 수 있는 기능 중 하나는 바로 interestTarget 속성이다. 일반적으로 popoverTarget은 버튼 클릭 등의 명시적인 사용자 행동을 통해 popover를 열도록 동작하지만, interestTarget은 마우스 호버와 같이 더 암묵적인 관심(interest)을 감지하여 popover를 띄운다. 예를 들어, 버튼이나 앵커 태그 위로 마우스를 가져가기만 해도 hint popover가 자동으로 표시된다.
이러한 동작은 사용자에게 부담을 주지 않으면서도 즉각적인 피드백을 제공하는 데 매우 유용하다. 특히 툴팁, 힌트, 인라인 도움말처럼 작고 맥락적인 정보 제공이 중요한 UI 요소에 적합하다. 또한 interestTarget은 키보드 포커스나 터치 이벤트까지도 고려할 수 있기 때문에, 단순히 시각적 인터랙션을 넘어서 접근성 측면에서도 장점을 제공한다. 이를 잘 활용하면, 사용자 경험을 방해하지 않으면서도 풍부한 정보를 자연스럽게 전달할 수 있다.

<a interesttarget="my-hovercard" href="...">
  Hover to show the hovercard
</a>
<span popover="hint" id="my-hovercard">
  This is the hovercard
</span>

 

:popover-open 가상 클래스와 비슷하게 interestTarget은 :has-interest 가상 클래스와 함께 사용할 수 있다. 이를 통해 사용자가 요소에 관심을 보일 때, 스타일을 동적으로 조절하는 것이 가능하다.

 

anchor-positioning

생각해보면 popover는 일반적으로 버튼 옆에 뜨는 것이 자연스럽다. 하지만 이를 구현하려면 버튼과 popover가 공통으로 포함된 부모 요소를 만들어 position: relative를 걸고, popover를 그 기준으로 위치시켜야 한다. 결국 위치 지정만을 위해 불필요한 중간 태그가 추가되거나, popover를 버튼 내부에 넣는 방식으로 우회하게 되는 경우가 많다.

이러한 한계를 해결하기 위해 도입된 CSS 문법이 앵커 포지셔닝(anchor positioning)이다. 여기서 말하는 '앵커(anchor)'는 HTML의 <a> 태그가 아니라, 위치 기준이 되는 참조 요소를 의미한다. 앵커 포지셔닝을 사용하면 부모-자식 관계가 아니더라도 원하는 요소를 기준으로 위치를 지정할 수 있다. 문법은 container query와 유사한데, container query에서 container-name을 지정했던 것처럼, 앵커 포지셔닝에서는 기준 요소에 anchor-name을 지정하고, 위치를 잡을 요소에 position-anchor를 선언하여 연결할 수 있다. 이를 통해 구조를 더 깔끔하게 유지하면서도, 다양한 위치 지정이 가능해진다.

// css
.anchorName {
  anchor-name: --popoverTest;
}

.positionAnchor {
  position-anchor: --popoverTest;
  inset: auto;
  top: anchor(bottom);
  right: anchor(right); 
}

// tsx
export default function Page() {
  return (
    <>
      <button className={styles.anchorName} popoverTarget="popover">팝오버 오픈</button>

      <div popover='auto' id="popover" className={styles.positionAnchor}>asdf</div>
    </>
  )
}

 

만약 페이지에 동일한 anchor-name이 여럿이라면, ─ 확실한 것은 아니지만 몇 번의 실험을 통해 확인해본 결과 ─ position-anchor는 전체 노드에서 가장 아래에 있는 앵커 요소를 기준으로 위치를 잡는 것 같다. 따라서 가능하다면 anchor-name을 고유한 것으로 취급하는 것이 좋겠다.

 

anchor 함수는 앵커 요소의 top bottom left right 위치를 리턴하는데, 편리하기는 하지만 anchor 함수만을 사용해 '정확히 앵커 요소의 가운데'에 팝오버를 위치시키기란 여간 쉬운 일이 아니다. 이 문제를 해결하기 위해 justify-self, align-self, justify-items, align-items 요소에 anchor-center 값이 추가되었다.

 

position-area를 통해 위치를 처리할 수도 있는데, https://chrome.dev/anchor-tool/ 에서 모든 경우의 수를 간단히 확인해볼 수 있다.

 

multi anchor

어떤 태그는 두 개 이상의 앵커에 연결될 수 있다. 즉, 두 개 이상의 앵커를 기준으로 태그의 위치를 설정할 수 있다는 뜻이다. anchor() 함수를 사용하고 첫 번째 인수에서 참조하는 앵커를 명시적으로 지정하면 된다. 아래의 예시에서 배치된 요소의 왼쪽 상단은 한 앵커의 오른쪽 하단에 고정되고 배치된 요소의 오른쪽 하단은 두 번째 앵커의 왼쪽 상단에 고정된다.

.anchored {
  position: absolute;
  top: anchor(--one bottom);
  left: anchor(--one right);
  right: anchor(--two left);
  bottom: anchor(--two top);
}

 

@position-try

position-anchor로 팝오버의 위치를 지정했지만, 환경에 따라서 ─ 즉, 데스크탑에서는 제대로 보였던 게 모바일에서는 ─ 팝오버의 일부 내용이 가려지거나 할 수도 있다. 다행히 CSS를 만드는 똑똑한 사람들은 이럴 때를 대비한 기능도 이미 만들어두었다. @position-try와 position-try-fallbacks를 사용하면 팝오버가 화면에 가려지는 경우 ─ 정확히는 position-anchor로 위치를 지정한 요소의 일부가 화면 밖에서 랜더링되는 경우 ─ 추가적인 규칙으로 이를 바로잡을 수 있게 한다.

// css
.anchorName {
  anchor-name: --popoverTest;
}

.positionAnchor {
  position-anchor: --popoverTest;
  inset: auto;
  top: anchor(bottom);
  right: anchor(right);
  position-try-fallbacks: --toTheLeft, --toTheTop;
}

@position-try --toTheLeft {
 inset: unset;
 right: anchor(left);
}

@position-try --toTheTop {
 inset: unset;
 top: anchor(top);
}

 

브라우저에 미리 내장된 플립 키워드를 사용하면 훨씬 간단하게 처리할 수 있다.

.positionAnchor {
  position-anchor: --popoverTest;
  inset: auto;
  top: anchor(bottom);
  right: anchor(right);
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}

 

 

결론

아주 많은 문법이 생겨나고 변경되었지만 여전히 해결되지 않은 문제들이 있다. 이제 container query는 여러 브라우저에서 널리 사용할 수 있고 tailwind v4 에서도 공식적으로 지원하지만, 오늘 다룬 내용의 반 이상은 크로미움 기반의 브라우저에서만 동작(closedBy, command 등)하거나, 아예 실험적 기능(interestTarget 등)이라 언제든 없어지거나 변경될 수 있다. 오래된 브라우저 지원까지 고려하면 머리가 쪼개질 거 같다.

 

다행히 Interop 프로젝트(Bocoup, Igalia, Google, Microsoft, Apple, Mozilla가 함께 브라우저 간 상호 운용성 향상을 목표로 매년 새롭게 추가하거나 삭제할 표준을 결정하고, 이를 브라우저에 반영하는 프로젝트)는 최근 몇 년간 꾸준한 성과를 내고 있다. 당장 Interop 2025에는 앵커 포지셔닝 외에도 backdrop-filter나 @scope, scroll 관련 속성들이 포함되어있다.

 

여러 개발자들이 힘내주고 있는 건 알지만, 좀 더 힘을 내줬으면 한다 :)

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기