junyeokk
Blog
React-Ecosystem·2025. 08. 31

frimousse

웹 앱에서 이모지 피커가 필요한 상황은 생각보다 흔하다. 댓글 시스템, 채팅, 리액션 기능 등에서 사용자가 이모지를 선택할 수 있어야 한다. 그런데 이 "이모지 선택" UI를 직접 구현하려면 생각보다 복잡한 문제들이 있다.

이모지 데이터 자체가 방대하다. Unicode 표준의 이모지는 수천 개에 달하고, 카테고리 분류, 검색, 스킨톤 변형까지 처리해야 한다. 거기에 디바이스나 OS 버전에 따라 지원하지 않는 이모지가 있어서 깨진 문자가 표시되는 문제도 있다. 이걸 전부 리스트로 렌더링하면 수천 개의 DOM 노드가 생성되면서 성능이 급격히 떨어진다.

기존에 많이 사용되던 라이브러리들은 대부분 "완성된 UI"를 제공하는 방식이었다. emoji-mart 같은 라이브러리가 대표적인데, 스타일이 이미 정해져 있고 커스터마이징의 폭이 제한적이다. 디자인 시스템에 맞추려면 CSS를 덮어써야 하고, 그마저도 내부 구조가 복잡해서 원하는 대로 수정하기 어렵다. 번들 사이즈도 무시할 수 없다.

frimousse는 이 문제를 "headless" 방식으로 해결한다. Radix UI가 드롭다운이나 다이얼로그의 동작만 제공하고 스타일은 사용자에게 맡기는 것처럼, frimousse는 이모지 피커의 핵심 로직(데이터 로딩, 검색, 가상화, 키보드 네비게이션, 접근성)만 제공하고 스타일링은 완전히 열어둔다.


왜 frimousse인가

frimousse가 기존 이모지 피커 라이브러리와 차별화되는 지점은 크게 네 가지다.

의존성 제로, 가벼운 번들. 외부 의존성이 없다. Tree-shaking도 지원해서 사용하지 않는 부분은 번들에 포함되지 않는다. emoji-mart 같은 라이브러리가 수십 KB를 차지하는 것과 비교하면 상당한 차이다.

Unstyled + Composable. 스타일이 전혀 없다. Tailwind CSS든, CSS Modules든, styled-components든 원하는 방식으로 스타일링할 수 있다. 각 파트가 독립적인 컴포넌트로 분리되어 있어서 원하는 조합으로 구성할 수 있다.

자동 이모지 데이터 관리. 이모지 데이터를 번들에 포함시키지 않고, 필요할 때 네트워크에서 가져와서 로컬에 캐싱한다. 덕분에 항상 최신 이모지 데이터를 사용하면서도 초기 번들 사이즈에 영향을 주지 않는다.

깨진 이모지 자동 필터링. 디바이스가 지원하지 않는 이모지는 자동으로 숨긴다. 사용자 환경에서 가 표시되는 일이 없다.


기본 구조

frimousse의 컴포넌트는 EmojiPicker라는 네임스페이스 아래 여러 파트로 구성된다.

tsx
import { EmojiPicker } from "frimousse";

function MyEmojiPicker() {
  return (
    <EmojiPicker.Root>
      <EmojiPicker.Search />
      <EmojiPicker.Viewport>
        <EmojiPicker.Loading>로딩 중…</EmojiPicker.Loading>
        <EmojiPicker.Empty>이모지를 찾을 수 없습니다.</EmojiPicker.Empty>
        <EmojiPicker.List />
      </EmojiPicker.Viewport>
    </EmojiPicker.Root>
  );
}

이게 가장 기본적인 형태다. 각 파트의 역할은 다음과 같다.

컴포넌트역할
EmojiPicker.Root전체를 감싸는 컨테이너. 전역 옵션(로케일, 이모지 버전 등) 설정
EmojiPicker.Search검색 입력 필드. controlled/uncontrolled 모두 지원
EmojiPicker.Viewport스크롤 가능한 영역
EmojiPicker.List이모지 리스트. 내부적으로 가상화(virtualization) 적용
EmojiPicker.Loading이모지 데이터 로딩 중에만 렌더링
EmojiPicker.Empty검색 결과가 없을 때만 렌더링

이 구조가 좋은 이유는 각 파트를 자유롭게 배치하고 스타일링할 수 있다는 점이다. 검색 필드를 아래에 놓고 싶으면 EmojiPicker.SearchViewport 아래로 옮기면 된다. 로딩 상태에 스피너를 넣고 싶으면 Loading 안에 스피너 컴포넌트를 넣으면 된다.


리스트 커스터마이징

EmojiPicker.Listcomponents prop으로 내부 요소들의 렌더링을 완전히 제어할 수 있다. 세 가지 내부 컴포넌트를 커스텀할 수 있다.

tsx
<EmojiPicker.List
  components={{
    Row: MyRow,
    Emoji: MyEmoji,
    CategoryHeader: MyCategoryHeader,
  }}
/>

각 컴포넌트가 받는 props 타입이 정의되어 있어서 TypeScript와도 잘 동작한다.

tsx
import type {
  EmojiPickerListRowProps,
  EmojiPickerListEmojiProps,
  EmojiPickerListCategoryHeaderProps,
} from "frimousse";

function MyRow({ children, ...props }: EmojiPickerListRowProps) {
  return (
    <div {...props} className="scroll-my-1 px-1">
      {children}
    </div>
  );
}

function MyEmoji({ emoji, ...props }: EmojiPickerListEmojiProps) {
  return (
    <button
      {...props}
      className="flex size-8 items-center justify-center rounded hover:bg-gray-100"
    >
      {emoji.emoji}
    </button>
  );
}

function MyCategoryHeader({ category, ...props }: EmojiPickerListCategoryHeaderProps) {
  return (
    <div {...props} className="px-2 py-1 text-xs text-gray-500">
      {category.label}
    </div>
  );
}

여기서 중요한 점이 하나 있다. RowEmoji 컴포넌트는 동일한 크기여야 한다. frimousse가 내부적으로 가상화(virtualization)를 사용하기 때문에, 각 행의 높이가 달라지면 스크롤 위치 계산이 틀어져서 레이아웃이 깨진다.


가상화(Virtualization)

frimousse의 성능이 좋은 핵심 이유가 가상화다. 수천 개의 이모지를 전부 DOM에 렌더링하지 않고, 현재 뷰포트에 보이는 행만 렌더링한다. 사용자가 스크롤하면 보이는 영역에 맞춰 렌더링할 행이 동적으로 바뀐다.

이 방식 덕분에 이모지가 아무리 많아도 DOM 노드 수가 일정하게 유지된다. 실제로 렌더링되는 노드는 보이는 영역 + 약간의 오버스캔 영역뿐이다.

별도의 가상화 라이브러리(react-virtuoso, tanstack-virtual 등)를 쓰지 않고 자체 구현이 내장되어 있어서, 추가 의존성 없이 성능 최적화가 적용된다.


스킨톤(Skin Tone)

이모지에는 스킨톤 변형이 있다. 👋🏻 👋🏼 👋🏽 👋🏾 👋🏿 같은 식이다. frimousse는 이를 처리하기 위해 두 가지 방법을 제공한다.

기본 제공 SkinToneSelector

가장 간단한 방법이다. 버튼 하나를 렌더링하고, 클릭할 때마다 스킨톤이 순환된다.

tsx
<EmojiPicker.Root>
  <div className="flex items-center gap-2 p-2">
    <EmojiPicker.Search />
    <EmojiPicker.SkinToneSelector />
  </div>
  <EmojiPicker.Viewport>
    <EmojiPicker.List />
  </EmojiPicker.Viewport>
</EmojiPicker.Root>

커스텀 구현 (SkinTone 컴포넌트 / useSkinTone 훅)

더 세밀한 제어가 필요하면 EmojiPicker.SkinTone 컴포넌트나 useSkinTone 훅을 사용할 수 있다. 드롭다운으로 스킨톤을 선택하는 UI를 만들거나, 스킨톤 상태를 외부에서 관리할 때 유용하다.

tsx
import { useSkinTone } from "frimousse";

function MySkinTonePicker() {
  const { skinTone, setSkinTone } = useSkinTone();
  // skinTone: "none" | "light" | "medium-light" | "medium" | "medium-dark" | "dark"
  return (
    <select
      value={skinTone}
      onChange={(e) => setSkinTone(e.target.value)}
    >
      <option value="none">기본</option>
      <option value="light">밝은</option>
      <option value="medium">중간</option>
      <option value="dark">어두운</option>
    </select>
  );
}

ActiveEmoji (미리보기)

현재 호버하거나 키보드로 선택한 이모지의 정보를 보여주는 미리보기 영역을 만들 수 있다. Slack이나 Discord의 이모지 피커 하단에 있는 그 영역이다.

tsx
<EmojiPicker.ActiveEmoji>
  {({ emoji }) =>
    emoji ? (
      <div className="flex items-center gap-2 p-2 border-t">
        <span className="text-xl">{emoji.emoji}</span>
        <span className="text-xs text-gray-500">{emoji.label}</span>
      </div>
    ) : (
      <div className="p-2 border-t text-xs text-gray-400">
        이모지를 선택하세요…
      </div>
    )
  }
</EmojiPicker.ActiveEmoji>

render callback 패턴을 사용하기 때문에 emojinull일 때와 있을 때를 자유롭게 처리할 수 있다. useActiveEmoji 훅으로도 같은 기능을 사용할 수 있다.


이벤트 처리

이모지를 선택했을 때의 처리는 EmojiPicker.RootonEmojiSelect prop으로 한다.

tsx
<EmojiPicker.Root
  onEmojiSelect={(emoji) => {
    console.log(emoji.emoji);  // "😀"
    console.log(emoji.label);  // "grinning face"
    // 선택된 이모지를 인풋에 삽입하거나, 리액션을 추가하는 등의 로직
  }}
>
  {/* ... */}
</EmojiPicker.Root>

emoji 객체에는 이모지 문자(emoji), 이름(label), 카테고리, 태그 등의 정보가 포함되어 있다.


shadcn/ui 통합

frimousse는 shadcn/ui와의 통합을 공식 지원한다. shadcn CLI로 한 줄이면 이미 스타일링된 컴포넌트를 설치할 수 있다.

bash
npx shadcn@latest add https://frimousse.liveblocks.io/r/emoji-picker

이렇게 설치하면 shadcn/ui의 디자인 토큰(색상, 간격, 둥글기 등)에 맞춰진 이모지 피커 컴포넌트가 components/ui/emoji-picker.tsx에 생성된다. 이 컴포넌트는 frimousse의 primitive 위에 shadcn/ui 스타일을 입힌 것이기 때문에, 필요하면 파일을 직접 수정해서 커스터마이징할 수 있다.

Popover와 결합하는 것도 자연스럽다.

tsx
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { EmojiPicker, EmojiPickerSearch, EmojiPickerContent, EmojiPickerFooter } from "@/components/ui/emoji-picker";

function EmojiButton({ onSelect }: { onSelect: (emoji: string) => void }) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        <button>😀</button>
      </PopoverTrigger>
      <PopoverContent className="w-fit p-0">
        <EmojiPicker
          className="h-[320px]"
          onEmojiSelect={(emoji) => onSelect(emoji.emoji)}
        >
          <EmojiPickerSearch placeholder="이모지 검색…" />
          <EmojiPickerContent />
          <EmojiPickerFooter />
        </EmojiPicker>
      </PopoverContent>
    </Popover>
  );
}

CSS 변수

EmojiPicker.Root는 내부적으로 CSS 변수를 노출한다. 그중 --frimousse-viewport-width가 특히 유용하다. 이 변수는 리스트의 실제 너비를 나타내는데, 이걸 max-width로 사용하면 Footer 같은 영역이 리스트보다 넓어지는 것을 방지할 수 있다.

css
.emoji-picker-footer {
  max-width: var(--frimousse-viewport-width);
}

이모지 피커의 너비는 하드코딩하지 않아도 된다. 열 수(columns)나 이모지 버튼 크기에 따라 자동으로 조정되기 때문이다. 물론 모바일에서 화면 전체 너비를 채우고 싶다면 width: 100%를 설정할 수도 있다.


리스트 패딩 처리

가상화된 리스트에 패딩을 적용하는 건 약간 까다롭다. EmojiPicker.List에 직접 padding을 주면 가상화 계산이 틀어질 수 있기 때문이다. 권장하는 방법은 다음과 같다.

  • 가로 패딩: Row와 CategoryHeader 커스텀 컴포넌트에 px 적용
  • 세로 패딩: List 자체에 py 적용
  • 키보드 스크롤 보정: Row에 scroll-my 값을 세로 패딩과 동일하게 설정
tsx
function MyRow({ children, ...props }: EmojiPickerListRowProps) {
  return (
    <div {...props} className="scroll-my-1 px-1">
      {children}
    </div>
  );
}

scroll-myscroll-margin-block의 Tailwind 축약인데, 키보드로 이모지를 네비게이션할 때 자동 스크롤이 패딩 영역을 고려하도록 해준다. 이게 없으면 키보드로 첫 번째/마지막 행으로 이동했을 때 이모지가 패딩에 가려져서 보이지 않는 문제가 생긴다.


접근성(Accessibility)

frimousse는 접근성을 자체적으로 처리한다.

  • 키보드 네비게이션: 화살표 키로 이모지 간 이동, Enter로 선택
  • 스크린 리더: 각 이모지에 적절한 aria-label이 자동 적용
  • 검색 필드 포커스: 피커가 열리면 검색 필드에 자동 포커스

별도로 role이나 aria-* 속성을 신경 쓸 필요 없이, 파트를 조합하기만 하면 접근성 기준을 충족하는 피커가 만들어진다.


대안 비교

라이브러리스타일번들 크기커스터마이징이모지 데이터
frimousseUnstyled~5KB완전 자유런타임 fetch + 캐시
emoji-martPre-styled~40KB+CSS 오버라이드번들 포함 or fetch
emoji-picker-reactPre-styled~30KB+제한적번들 포함

frimousse는 디자인 시스템이 있는 프로젝트에서 특히 강점이 크다. 기존 스타일에 완전히 맞출 수 있기 때문이다. 반면 빠르게 "그냥 동작하는" 이모지 피커가 필요하다면, 스타일이 이미 적용된 emoji-mart가 더 편할 수 있다.


정리

  • frimousse는 headless 이모지 피커로, 스타일 없이 로직(검색, 가상화, 접근성, 스킨톤)만 제공한다
  • 의존성 제로 + 런타임 이모지 데이터 fetch로 번들 사이즈 영향이 거의 없다
  • shadcn/ui와 공식 통합을 지원해서 기존 디자인 시스템에 바로 녹일 수 있다

관련 문서

  • Compound Component 패턴 - frimousse가 사용하는 컴포넌트 설계 패턴
  • cmdk - 비슷한 composable 방식의 UI 라이브러리
  • Radix UI - frimousse와 같은 headless 철학의 UI 프리미티브