툴바부터 테마까지 내가 원하는 대로
TipTap은 디자인 자유도가 높은 리치 텍스트 에디터로
오늘은 아주 유용한 TipTap에 대해서 이야기 해보려고 한다!
이 글에서 다룰 것
TipTap을 쓰면 뭐가 좋은지 한 줄 정리
React에서 설치 → 최소 예제 → 저장/불러오기까지
툴바 버튼 직접 만들어 보기(볼드/이탤릭/헤딩)
스타일링 팁과 Next.js(SSR) 주의점
자주 겪는 이슈 Q&A
왜 TipTap일까?
헤드리스(Headless):
“편집기 로직”만 있고, UI는 전부 내가 만든다
→ 디자인 시스템(Tailwind, shadcn/ui 등)과 찰떡궁합!
확장(Extension) 기반: 필요한 기능만 쏙쏙 무겁지 않게
ProseMirror 위라서 안정적이고 커스터마이징 폭이 큼
“회사/프로젝트의 룩앤필을 해치지 않고, 에디터를 내 디자인처럼”
설치 (3줄 컷)
npm create vite@latest my-tiptap -- --template react-ts
cd my-tiptap
npm i @tiptap/react @tiptap/pm @tiptap/starter-kit
최소 예제
// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function Tiptap() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello TipTap! <strong>Bold</strong> 테스트</p>',
})
if (!editor) return null
return <EditorContent editor={editor} />
}
// src/App.tsx
import Tiptap from './Tiptap'
export default function App() {
return (
<main style={{ maxWidth: 720, margin: '40px auto' }}>
<h1>My TipTap Editor</h1>
<Tiptap />
</main>
)
}
여기까지가 “보여주기”의 끝!
이제부터 “쓰기 편하게 만들기”를 해보자
가장 많이 쓰는 툴바 3종 세트 만들기
TipTap은 버튼을 기본 제공하지 않는다.
대신 editor.chain().focus().toggleBold().run() 같은 명령을 호출하면 끝!
// src/Toolbar.tsx
import { useCurrentEditor } from '@tiptap/react'
export default function Toolbar() {
const { editor } = useCurrentEditor()
if (!editor) return null
const Button = (props: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button
{...props}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid #ddd',
background: '#fff',
cursor: 'pointer',
}}
/>
)
return (
<div style={{ display: 'flex', gap: 8, margin: '12px 0' }}>
<Button
onClick={() => editor.chain().focus().toggleBold().run()}
style={{ fontWeight: editor.isActive('bold') ? 700 : 400 }}
>
B
</Button>
<Button
onClick={() => editor.chain().focus().toggleItalic().run()}
style={{ fontStyle: editor.isActive('italic') ? 'italic' : 'normal' }}
>
I
</Button>
<Button onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
H2
</Button>
<Button onClick={() => editor.chain().focus().setParagraph().run()}>
P
</Button>
<Button onClick={() => editor.chain().focus().undo().run()}>
Undo
</Button>
<Button onClick={() => editor.chain().focus().redo().run()}>
Redo
</Button>
</div>
)
}
// src/Tiptap.tsx (툴바 붙이기)
import { useEditor, EditorContent, EditorContentProps, Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Toolbar from './Toolbar'
export default function Tiptap() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>여기에 글을 입력해보세요 ✍️</p>',
})
if (!editor) return null
return (
<>
<Toolbar />
<EditorContent editor={editor} className="tiptap" />
</>
)
}
저장/불러오기(로컬 저장소 기준)
TipTap은 JSON/HTML 둘 다 지원한다.
보통은 JSON 저장이 구조를 보존하기 좋다!
// src/SaveLoad.tsx
import { useCurrentEditor } from '@tiptap/react'
export default function SaveLoad() {
const { editor } = useCurrentEditor()
if (!editor) return null
const save = () => {
const json = editor.getJSON()
localStorage.setItem('post', JSON.stringify(json))
alert('저장 완료!')
}
const load = () => {
const raw = localStorage.getItem('post')
if (!raw) return alert('저장된 내용이 없어요')
editor.commands.setContent(JSON.parse(raw))
}
return (
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button onClick={save}>저장</button>
<button onClick={load}>불러오기</button>
</div>
)
}
// src/Tiptap.tsx (저장/불러오기 UI 포함)
import SaveLoad from './SaveLoad'
// ...
return (
<>
<Toolbar />
<SaveLoad />
<EditorContent editor={editor} className="tiptap" />
</>
)
스타일링 팁 (진짜 중요)
TipTap은 스타일을 거의 안 주고 보낸다.
그래서 최소한 아래 정도는 잡아두면 보기 좋아집니다.
/* src/index.css */
.tiptap {
min-height: 220px;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
line-height: 1.7;
background: #fff;
}
/* 본문 요소들 */
.tiptap p { margin: 0 0 0.8em; }
.tiptap h1, .tiptap h2, .tiptap h3 { font-weight: 700; line-height: 1.3; margin: 1.2em 0 0.6em; }
.tiptap ul, .tiptap ol { padding-left: 1.4em; margin: 0.8em 0; }
.tiptap code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; padding: 2px 6px; border-radius: 6px; background: #f5f5f5; }
.tiptap pre { background: #0b1020; color: #e6edf3; padding: 12px 14px; border-radius: 12px; overflow: auto; }
.tiptap blockquote { border-left: 4px solid #e5e7eb; margin: 1em 0; padding-left: 12px; color: #6b7280; }
Tailwind를 쓴다면 className="prose" 같은 걸 바로 붙이지 말고, .tiptap 범위에 필요한 스타일만 얇게 깔아주는 걸 추천한다!
(툴바/테마랑 충돌 적음)
Next.js(SSR)이라면 이 옵션을 꼭 기억하자!
SSR에서는 에디터를 클라이언트에서만 그리게 해야 경고가 안 뜬다.
'use client'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export default function MyEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>SSR 안전 모드</p>',
immediatelyRender: false, // ← 요게 포인트!
})
if (!editor) return null
return <EditorContent editor={editor} className="tiptap" />
}
자주 겪는 이슈 Q&A
Q1. 왜 기본 툴바가 없죠?
A. TipTap은 헤드리스라 일부러 안 넣었다.
대신 editor.commands.*나 editor.chain()으로 버튼을 내 입맛대로 만들 수 있다.
Q2. HTML로 저장하면 안 되나요?
A. 가능합니다. 다만 “구조”를 유지해야 하거나 나중에 머지/검증을 하려면 JSON이 더 좋다. (서버에서 파싱/검증도 쉬움)
Q3. 긴 문서에서 렉이 걸려요.
A. 툴바/상태 표시 컴포넌트를 쪼개고, editor.isActive() 호출을 최소화하자! 필요하면 하이라이트/구문강조 같은 무거운 작업은 지연 처리를 고려하자.
Q4. 컴포넌트에서 editor가 자꾸 null이에요.
A. useEditor()는 초기 렌더에서 null일 수 있다.
if (!editor) return null을 기억하자!
확장(Extension) 선택 가이드 (입문 추천)
StarterKit:
문단/헤딩/리스트/코드블럭/수평선/볼드/이탤릭/취소/재실행 등 기본 풀세트
Link / Image / Table / Placeholder / Mention: 필요할 때 하나씩 추가
CodeBlockLowlight: 코드 하이라이트가 필요할 때
팁: “지금 당장 안 쓰는 기능”은 과감히 빼두자
에디터는 가볍고 빠르게 느껴지는 게 가장 중요하다.