highlight-kit
CSS Custom Highlight API 기반 텍스트 하이라이트 core와 React 어댑터
데모
highlight-kit · controller + provider + range overlay
sink 분리로 bookkeeping/CSS write 격리 · getRanges 로 위치 시각화 · Provider 주입.
1. Search + navigation
useHighlightSearch()2. Multiple names
<Highlight.Match />3. RegExp patterns
no DOM rewrite// quicksort in-place
function quicksort(arr, lo = 0, hi = arr.length - 1) {
if (lo < hi) {
const p = partition(arr, lo, hi);
return quicksort(arr, lo, p - 1);
}
}store inspector
(useHighlightSnapshots · getRanges)no active highlights
행을 클릭하면 controller.getRanges(name) → Range.getClientRects() 로 해당 Range들의 실제 위치를 화면에 오버레이합니다.
설치
pnpm add @cbcruk/highlight-kitESM 전용 패키지이며 타입 정의가 함께 포함됩니다. React 어댑터(@cbcruk/highlight-kit/react)는
react >= 18을 필요로 하지만 optional peer dependency라, core만 쓰면 React 없이 동작합니다.
사용법
Core (프레임워크 무관)
import {
highlights,
computeRanges,
injectHighlightStyles,
} from '@cbcruk/highlight-kit'
injectHighlightStyles({
search: { backgroundColor: '#fef08a', color: '#854d0e' },
})
const el = document.querySelector('#article')!
const ranges = computeRanges(el, 'wisdom', { caseSensitive: false })
// (name, sourceId, ranges) — 같은 name에 여러 source가 union 됨
highlights.set('search', 'my-source', ranges)
highlights.remove('search', 'my-source') // 한 source만 제거
highlights.clear('search') // name 통째로 제거핵심: set/remove/clear는 name별로 여러 source의 기여를 합쳐 단일 Highlight로
reconcile합니다. 서로 다른 패널 두 곳에서 'error' 이름으로 하이라이트해도, CSS는
::highlight(error) 규칙 하나로 둘 다 스타일링됩니다.
React
선언적 컴포넌트
import { Highlight } from '@cbcruk/highlight-kit/react'
function Article({ keyword }: { keyword: string }) {
return (
<Highlight query={keyword} name="search">
<article>{/* ...긴 본문... */}</article>
</Highlight>
)
}::highlight(search) {
background: #fef08a;
color: #854d0e;
}as prop으로 wrapper 태그 변경, 레이아웃 영향을 없애려면 display: contents:
<Highlight query={q} name="search" as="section" style={{ display: 'contents' }}>
{children}
</Highlight>Headless 훅
import { useHighlight } from '@cbcruk/highlight-kit/react'
function SearchableText({ query }: { query: string }) {
const { ref, count, active } = useHighlight<HTMLDivElement>({
query,
name: 'search',
caseSensitive: false,
})
return (
<>
<span>{count}개 일치</span>
<div ref={ref}>{/* ...본문... */}</div>
</>
)
}name을 생략하면 useId 기반 고유 이름이 자동 생성됩니다(인스턴스별 격리).
검색 + 네비게이션
import { useRef, useState } from 'react'
import {
HighlightStyles,
useHighlightSearch,
} from '@cbcruk/highlight-kit/react'
function Search() {
const ref = useRef<HTMLDivElement>(null)
const [query, setQuery] = useState('wisdom')
const { count, active, next, prev } = useHighlightSearch(ref, query)
return (
<>
<HighlightStyles />
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<span>{count === 0 ? '0/0' : `${active + 1}/${count}`}</span>
<button onClick={prev}>↑</button>
<button onClick={next}>↓</button>
<div ref={ref}>...본문...</div>
</>
)
}전체 매치는 search, 현재 항목은 더 높은 priority의 search-current 이름으로
하이라이트되고 자동으로 scrollIntoView됩니다. options.name으로 기본 이름을 바꿀 수
있습니다. <HighlightStyles />는 이 두 이름의 기본 색상을 넣어 줍니다.
한 컨테이너에 여러 패턴: Highlight.Root + Highlight.Match
import { Highlight } from '@cbcruk/highlight-kit/react'
function Logs({ logs }: { logs: string }) {
return (
<Highlight.Root name="log-info" as="pre">
{logs}
<Highlight.Match name="log-error" pattern={/ERROR:[^\n]*/} />
<Highlight.Match name="log-warn" pattern={/WARN:[^\n]*/} />
{/* name 생략 → Root의 name */}
<Highlight.Match pattern={/INFO:[^\n]*/} />
</Highlight.Root>
)
}Highlight.Match는 effect-only(return null)입니다. Root 컨테이너를 스캔해 하이라이트만
등록하며, 기본으로 DOM 변경을 MutationObserver로 추적합니다.
상태만 구독 / 지원 여부
import {
useHighlightState,
useHighlightSupport,
} from '@cbcruk/highlight-kit/react'
const { count, active } = useHighlightState('search') // 읽기 전용 구독
const all = useHighlightSnapshots() // { [name]: { count, active } }
const supported = useHighlightSupport() // SSR 중엔 false테스트 / controller 주입
jsdom에는 Highlight가 없으므로, 부수효과 없는 sink로 만든 controller를 주입해
bookkeeping만 검증합니다.
import {
createHighlightController,
createNoopSink,
} from '@cbcruk/highlight-kit'
import { HighlightProvider } from '@cbcruk/highlight-kit/react'
const controller = createHighlightController({ sink: createNoopSink() })
render(
<HighlightProvider controller={controller}>
<SearchUI />
</HighlightProvider>,
)
expect(controller.getSnapshot('search').count).toBe(3)API
core
| export | 설명 |
|---|---|
highlights | singleton 컨트롤러 (set / remove / clear / clearAll / getRanges / subscribe / getSnapshot / getSnapshots) |
createHighlightController({ sink? }) | 격리된 컨트롤러 생성 (테스트·Provider용) |
createCssHighlightSink() | CSS.highlights에 반영하는 기본 sink |
createNoopSink() | 부수효과 없는 sink (SSR/테스트용, 미지원 환경에서도 bookkeeping 수행) |
computeRanges(root, pattern, opts?) | 패턴(string/RegExp) 매칭 → Range[] (caseSensitive / wholeWord 옵션) |
rangesFromOffsets(root, spans) | 문자 offset 배열 → Range[] (root.textContent 기준, 공백만 있는 텍스트 노드 포함) |
getTextNodes(root) | 하위 텍스트 노드 수집 (공백만 있는 노드 제외) |
isHighlightSupported() | API 지원 여부 |
generateHighlightCSS(styles) | ::highlight() CSS 문자열 생성 (주입 없이 반환) |
injectHighlightStyles(styles, id?) | ::highlight() CSS 동적 주입 (<style> 삽입) |
react (@cbcruk/highlight-kit/react)
| export | 설명 |
|---|---|
useHighlight(opts) | headless. ref + { count, active, name } 반환 (priority, observe 옵션) |
useHighlightSearch(ref, query, opts?) | 검색 + next/prev 네비게이션 |
useTextMatches(target, pattern, opts?) | 매칭된 Range[] 추적 (ref 또는 element, observe 기본 true) |
useHighlightRanges(name, ranges, priority?) | 직접 계산한 Range[] 등록 (effect-only) |
useHighlightState(name) | name의 { count, active } 읽기 전용 구독 |
useHighlightSnapshots() | 모든 활성 name의 스냅샷 구독 |
useHighlightSupport() | 지원 여부 (SSR-safe) |
useHighlightController() | 현재 scope의 컨트롤러 |
<HighlightProvider controller?> | 컨트롤러 주입 (생략 시 격리된 컨트롤러 생성) |
<Highlight query name as> | 선언적 wrapper |
<Highlight.Root> / <Highlight.Match> | 한 컨테이너에 여러 패턴 |
<HighlightStyles styles?> | ::highlight() 규칙 <style> (기본: search / search-current) |
타입도 함께 export됩니다: MatchOptions, HighlightSnapshot, HighlightController,
HighlightSink, SourceId, UseHighlightOptions, UseHighlightResult,
UseHighlightSearchOptions, UseHighlightSearchResult, TextMatchOptions, HighlightProps,
HighlightRootProps, HighlightMatchProps, HighlightStyleMap.
설계 노트 / 한계
- DOM 변경 추적:
useHighlight/<Highlight>는 기본적으로query/name/옵션이 바뀔 때만 재계산합니다. 동적 콘텐츠면observe를 켜세요.useTextMatches/useHighlightSearch/<Highlight.Match>는 기본으로 추적합니다. - box model 미지원: 둥근 테두리 등이 필요하면
controller.getRanges(name)→Range.getClientRects()로 overlay를 직접 그립니다 (src/demo.tsx참고). - hit-test 불가: 하이라이트는 클릭/hover 대상이 아닙니다.
- 이름은 document 전역:
HighlightProvider격리는 구독 그래프/테스트용이며, 같은 DOM에서 같은 이름을 두 컨트롤러가 칠하면 서로 덮어씁니다. - Shadow DOM:
TreeWalker는 shadow 경계를 넘지 않습니다. - SSR:
getServerSnapshot이 항상 빈 스냅샷을 반환하고, 등록은 layout effect에서 일어나므로 hydration 불일치가 없습니다. ::highlight()지원 속성:color,background-color,text-decoration(브라우저 차이 있음),text-shadow,-webkit-text-stroke/fill등 제한적.
브라우저 지원
Chrome/Edge 105+, Safari 17.2+, Firefox 140+ (2025-06~) — 전 메이저 브라우저 커버.
개발
eunsoolib 모노레포 패키지로 통합되어 있습니다. 진입점은 src/index.ts(core)와 src/react.tsx(React 어댑터)이며, src/demo.tsx에 검색·다중 이름·RegExp·range overlay 데모가 있습니다.
@cbcruk/use-highlight-search는 이 패키지로 통합되었습니다.HighlightStoreProvider→HighlightProvider,createHighlightStore→createHighlightController,useHighlight(name, ranges)→useHighlightRanges(name, ranges),store.getSnapshot()[name]→controller.getSnapshot(name).count로 옮기면 됩니다.
pnpm test:run packages/highlight-kit # Vitest (jsdom)src/highlight-api.d.ts는 lib.dom이 아직 불완전하게 타입한 HighlightRegistry의
maplike 멤버(set/get/delete 등)를 보강하는 빌드 전용 선언입니다.
타입 (자동 생성)
UseHighlightOptions
Prop
Type
UseHighlightSearchOptions
Prop
Type
HighlightSink
Prop
Type