내 홈페이지에 iMessage 말풍선과 immersive hover를 입혔다
AI 90 / Human 10
AI-drafted, editedAI가 거의 다 쓰고, kimtoma가 일부 수정.
— 브라우저로 직접 보면서 피드백 → AI가 코드/디버깅. 시각적 판단·디자인 디렉션은 사람, 손은 AI.
며칠 전에 홈을 디렉토리 형태로 다시 만들었다. 그 이후 며칠 동안 더 다듬었다. dual view 토글, iMessage 모양 말풍선, hover 시 주변 텍스트가 자연스럽게 흐려지는 immersive 효과, bento 캔버스에 추가된 라이브 위젯들. 그리고 도중에 한번 진짜 pretext 같은 dynamic text reflow를 시도했다가 실패하고 원복했다.
이번 라운드에서 흥미로웠던 기술 디테일 몇 가지를 정리해둔다.
1. 단일 SVG path로 iMessage 말풍선 그리기
처음엔 두 개의 ::before / ::after pseudo-element로 말풍선 꼬리를 만들었다. 하나는 primary 색의 사각형, 하나는 페이지 배경색의 더 큰 사각형이 겹쳐서 둥근 꼬리 모양을 "잘라내는" 클래식 CSS 기법.
근데 hover 시 opacity 트랜지션이 들어가면 한 프레임 정도 직사각형 bounding box가 그대로 보였다 사라졌다. 사용자가 화면 녹화로 잡아준 깜빡임이었다.
원인은 alpha compositing — 두 사각형이 동시에 페이드 인되는 동안 둥근 모서리가 깔끔하게 cut되지 않고 raw 사각형 경계가 잠깐 노출되는 거였다.
해결: 두 도형 합성을 포기하고 하나의 SVG path로 말풍선 전체(몸통 + 꼬리)를 한 번에 그렸다.
<path d="M 38 0 H 138 A 16 16 0 0 1 154 16 V 20 A 16 16 0 0 1 138 36
H 38 C 34 36 31 35 28 33 C 25 34 21 36 15 35 C 16 35 22 32 22 26
V 16 A 16 16 0 0 1 38 0 Z"/>
- 위 두 모서리 + 오른쪽 변 + 아래 모서리: 일반 둥근 사각형
H 38 C 34 36 31 35 28 33: 바닥 끝에서 왼쪽 모서리 partway까지 cubic BezierC 25 34 21 36 15 35: partway 지점에서 꼬리 끝(15, 35)까지 — left-ward로 leaf 모양C 16 35 22 32 22 26: 꼬리 끝에서 left edge 위쪽(22, 26)까지 복귀V 16 A 16 16 0 0 1 38 0 Z: 왼쪽 변 + 왼쪽 위 모서리 + close
좌표는 iMessage의 실제 SVG 레퍼런스에서 가져와서 우리 154×36 viewBox에 맞게 비례 변환(가로 ×0.81, 세로 ×0.61). 한 element가 한 도형이라서 합성 단계가 없고, fill을 transparent → primary로 트랜지션해도 깜빡임이 없다.
2. CSS :has()로 주변 텍스트 블러
말풍선이 더 극적으로 튀어나오게 하고 싶었다. scale up + glow도 좋지만 진짜 "immersive" 느낌은 주변이 흐려질 때 나온다 — 카메라가 피사체에 포커스를 잡듯이.
main:has(.chat-greet:hover) header,
main:has(.chat-greet:hover) section,
main:has(.chat-greet:hover) footer,
main:has(.chat-greet:hover) p:has(.chat-greet) > *:not(.chat-greet) {
filter: blur(2px);
opacity: 0.45;
transition: filter 420ms ease, opacity 420ms ease;
}
:has() selector — 한 요소가 자식 중에 특정 셀렉터를 가지면 부모를 매치한다. JavaScript 없이 "chat-greet에 hover가 걸린 main"을 골라낸 다음, 그 안의 sibling들(header, section, footer)에 blur + opacity를 적용한다.
브라우저 지원도 충분히 널리 퍼져서 (Chrome 105+, Safari 15.4+, Firefox 121+, 모두 2023년 이전) 별다른 fallback 없이 쓸 수 있다.
말풍선 본체는 동시에 scale 0.95 → 1.22 + translateY(-3px) + primary tinted drop-shadow로 튀어나오고, 아바타는 scale 1.12 + rotate(-4deg)로 살짝 기운다. 결과: 페이지의 다른 모든 게 뒤로 빠지고 chat-greet만 앞으로 튀어나오는 시네마틱 depth. 평면 문서가 잠깐 입체가 된다.
bare text node의 함정
근데 처음엔 bio paragraph의 한국어 본문이 안 흐려졌다. 자세히 보니 paragraph 안의 <a> (KT R&D 연구소 링크)와 <em> (PAIGE)만 흐려지고 그 사이의 한국어 텍스트는 그대로.
이유: CSS filter는 element에만 적용되고 text node에는 직접 적용 안 된다. text node는 부모 element의 textContent의 일부일 뿐, 자체로 styling 대상이 아니다.
해결: bio paragraph 본문 전체를 단일 <span>으로 감싸서 그 span을 element로 만들었다.
<p>
<span>16년간 디자인 → ... 기본을 지키는 일을 좋아한다.</span>
<Link className="chat-greet">...</Link>
</p>
이제 p:has(.chat-greet:hover) > *:not(.chat-greet)가 그 wrapping span을 잡아서 안에 있는 모든 텍스트(<a>, <em>, bare text 포함)를 한 번에 흐린다.
3. 모바일에선 :hover가 없다 → IntersectionObserver
터치 디바이스에는 hover 상태가 없다. 위에서 만든 immersive 효과가 절대 안 발동한다.
대안: 사용자가 스크롤해서 chat-greet을 viewport 상단까지 끌어올리면(거의 화면 밖으로 나가기 직전) hover와 같은 효과를 자동으로 발동.
useEffect(() => {
const noHover = window.matchMedia('(hover: none)').matches
if (!noHover) return // 데스크탑은 그냥 hover에 맡김
const observer = new IntersectionObserver(
([entry]) => setChatGreetActive(entry.isIntersecting),
{
rootMargin: '0px 0px -80% 0px', // viewport 상단 20% 밴드
threshold: 0,
}
)
observer.observe(chatGreetRef.current!)
return () => observer.disconnect()
}, [])
rootMargin: '0px 0px -80% 0px': 인터섹션 영역의 bottom edge를 80% 위로 끌어올림. 결과적으로 viewport 상단 20%만 활성 영역이 된다. 사용자가 스크롤해서 chat-greet이 그 상단 밴드에 들어오면 setChatGreetActive(true) → .is-active class 붙임.
CSS 셀렉터는 모두 :hover, :focus-visible, .is-active 셋 다 받는다:
.chat-greet:hover .chat-greet-bubble,
.chat-greet:focus-visible .chat-greet-bubble,
.chat-greet.is-active .chat-greet-bubble {
transform: scale(1.22) translateY(-3px);
filter: drop-shadow(0 10px 24px hsl(var(--primary) / 0.4));
}
데스크탑은 :hover로, 모바일은 .is-active로 같은 효과 발동. 키보드 사용자는 :focus-visible.
처음엔 viewport 중앙 30% 밴드로 했더니 페이지 로드하자마자 트리거됐다. 사용자가 "처음부터 포커스되네 — 화면 밖으로 나가기 직전에만 발동시켜줘"라고 피드백. 상단 20%로 좁히고 해결.
4. Dual view + localStorage 영속화
기존 3D bento 홈이 마음에 드는 사용자도 있을 수 있어서 새 디렉토리 뷰랑 토글하게 만들었다.
- List view (기본): 새로 만든 디렉토리 레이아웃
- Widget view: 기존 3D 플로팅 카드 캔버스 (PortfolioCanvas)
토글 버튼은 우측 상단 fixed (z-10000), 좌측 네비게이션 Liquid Glass Dock과 똑같은 backdrop-blur(20px) saturate(180%) + linear-gradient 배경. 사용자가 한번 선택하면 localStorage.kimtoma:home-mode에 저장돼서 다음 방문에도 유지.
'use client'
const [mode, setMode] = useState<Mode>('list') // SSR 일관성을 위해 list 기본
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
const saved = localStorage.getItem('kimtoma:home-mode')
if (saved === 'list' || saved === 'widget') setMode(saved)
}, [])
return hydrated && mode === 'widget' ? <PortfolioCanvas /> : <HomeListView />
hydrated flag로 SSR 단계에선 무조건 list를 렌더 → hydration 후 저장된 preference 적용. 첫 페인트는 항상 list라서 SSR/CSR 불일치가 없다.
Widget view에는 라이브 데이터 위젯 3개를 추가했다 (Music 앨범 커버, Book 커버, Walking SVG path) — list view의 NOW.LOG 섹션과 같은 API를 쓰지만 더 시각적으로.
5. 시도했다가 실패한 것: pretext-style live reflow
pretext — Cheng Lou가 만든 라이브러리. 텍스트 안의 element를 드래그하면 텍스트가 실시간으로 그 주변을 reflow한다. 단순한 DOM 조작이 아니라, drag 위치를 따라 paragraph가 형태를 바꾼다.
비슷한 걸 chat-greet에 적용해보고 싶었다 — 사용자가 프로필+말풍선을 드래그하면 텍스트가 실시간으로 흐름을 바꾸면서 chat-greet 영역을 비켜가도록.
처음에 시도한 건 드롭 시 가장 가까운 슬롯에 snap. 7개의 슬롯(bio 끝, 각 섹션 사이)을 만들고 release 시점의 cursor Y에 가장 가까운 슬롯으로 chat-greet을 이동시키는 방식.
사용자 피드백: "그게 아니라 드롭하지 않아도 드래그 중에 실시간으로 텍스트가 reflow되는 게 진짜 pretext의 강점이야". 정확하다. 내가 만든 건 그냥 일반 drag-and-drop이지 pretext가 아니다.
근데 진짜 pretext-style을 CSS만으로 구현하기엔 한계가 명확하다:
- CSS
shape-outside는float에서만 작동. float은 같은 BFC(block formatting context) 안에서만 텍스트 wrap.- 여러 paragraph/section을 가로지르며 자유롭게 위치시키는 건 CSS 기본 기능으론 불가능.
진짜 pretext처럼 하려면 JavaScript로 텍스트를 spans로 잘라서 cursor 주변을 회피하면서 각각의 좌표를 계산하는 layout engine을 만들어야 한다. 한두 시간 작업이 아니고, 인터랙션 하나 위해 그만한 비용을 쓰는 게 합리적인지 의심스러웠다.
원복했다. 사용자도 "테스트해보고 안 되면 원복"이라고 미리 말해줬다.
이런 종류의 "기본 기능 너머의" 인터랙션은 그 자체가 콘텐츠일 때만 만들 가치가 있다. 내 홈에선 chat-greet이 메인이 아니라 보조 요소라서, 깔끔한 hover로 충분하다.
배운 것
- 단일 SVG가 두 개의 pseudo-element보다 거의 항상 낫다. 합성 단계가 줄어들면 깜빡임도 줄어든다.
:has()는 진짜 강력하다. "이 자식이 hover된 부모"를 골라낼 수 있다는 건 JavaScript 없이도 sibling들을 하나의 컨텍스트로 묶을 수 있다는 뜻. 페이지 전체의 시각적 무게를 한 element의 상태로 변형 가능.:hover가 없는 디바이스를 잊지 말 것. 모바일에서 hover 인터랙션은 발동 안 한다. 스크롤 위치, 터치 시간, focus-visible 등 다른 트리거를 같이 매핑해줘야 한다.- CSS-only로 가능한 일과 JS layout engine이 필요한 일의 경계. pretext-style live reflow는 후자. 인터랙션 디자인할 때 이 경계를 먼저 가늠하면 시간 낭비를 줄인다.
- 사용자 피드백 → 즉시 코드 변경의 사이클을 짧게 유지하면 된다. 화면 녹화로 깜빡임 잡아주는 사용자, 텍스트 노드가 안 흐려지는 걸 발견해주는 사용자. 그 디테일을 일일이 잡으려면 결국 사람 눈이 필요하다.
배포는 했다 — https://kimtoma.com 에서 우측 상단 토글 버튼 누르면 두 뷰 전환. bio 끝 "Wanna Chat?" 위에 마우스 올리면 immersive 효과 발동. 모바일에선 스크롤해서 위로 올리면 자동으로 발동. 일단 살아있다.