メインコンテンツへスキップ
kt-tech.blog

【実装】Notion calloutブロックをNext.jsでカラフルなUIコンポーネントとして表示する

実装12分で読めます

この記事でわかること

  • Notion の callout が Markdown 変換でただの引用になってしまう問題と、その回避
  • HTML を直接埋めずにプレースホルダーを介させる理由
  • Notion の全色 + ダークモードに対応する CSS の当て方
  • quote ブロックも同じ UI に寄せて見た目を揃える方法

Notion API でブロックを取得して Markdown に変換する実装がすでにあること。markdown-it などの変換器を使っている前提です。

概要: NotionのcalloutブロックをNext.jsブログで色分けされたリッチなUIボックスとして表示する実装方法を解説します。Blocks→Markdown→HTML変換でcalloutが消えてしまう問題を、プレースホルダー方式で解決しました。

はじめに

Notion で記事を書くとき、callout ブロックを多用して重要な情報を目立たせています。ブログ側でも同じように色分けされたボックスで見せたいのですが、Notion Blocks → Markdown → HTML の変換過程で callout の情報が失われてしまう問題がありました。


1. 問題: Blocks→Markdown変換でcalloutが消える

現象

Notion APIから取得したブロックデータをMarkdownに変換すると、calloutブロックが単なるblockquote(> )に変換されてしまう。

Notionでの表示

calloutブロックにはアイコンと背景色の情報がある:

JSON
{
  "type": "callout",
  "callout": {
    "icon": { "type": "emoji", "emoji": "💡" },
    "color": "green_background",
    "rich_text": [{ "plain_text": "これは重要なTipsです" }]
  }
}

一般的なMarkdown変換後

Markdown
> これは重要なTipsです

アイコンと色の情報が完全に失われ、ただのblockquoteになる。

2. 解決策: 3段階変換

全体の変換フローは以下の3段階:

Notion Blocks → :::calloutマーカー付きMarkdown → プレースホルダー → 最終HTML

なぜ直接HTMLを埋め込まないのか

Markdown→HTML変換(markdown-it)の過程で、Markdown内に直接書いたHTMLタグが除去・エスケープされてしまう場合がある。そのため、変換前にプレースホルダーに置換し、変換後にHTMLに復元する方式が必要。

3. Blocks→Markdown: calloutマーカー形式で出力

blocksToMarkdown関数のcallout処理

TypeScript
function blocksToMarkdown(blocks: Block[]): string {
  return blocks.map(block => {
    switch (block.type) {
      case 'callout': {
        const icon = block.callout.icon?.emoji || '💡';
        const color = block.callout.color || 'default';
        const text = richTextToMarkdown(block.callout.rich_text);
        return `:::callout{icon="${icon}" color="${color}"}\n${text}\n:::`;
      }
      // ... 他のブロックタイプ
    }
  }).join('\n\n');
}

出力されるMarkdown

Markdown
Tips
これは重要なTipsです

4. プレースホルダー変換

markdownToHtml前にプレースホルダーに置換

TypeScript
type CalloutData = {
  icon: string;
  color: string;
  content: string;
};

function replaceCalloutsWithPlaceholders(markdown: string): {
  markdown: string;
  callouts: Map<string, CalloutData>;
} {
  const callouts = new Map<string, CalloutData>();
  let index = 0;
  
  const replaced = markdown.replace(
    /:::callout\{icon="([^"]*)"\s+color="([^"]*)"\}\n([\s\S]*?)\n:::/g,
    (_, icon, color, content) => {
      const placeholder = `CALLOUT_PLACEHOLDER_${index++}`;
      callouts.set(placeholder, { icon, color, content });
      return placeholder;
    }
  );
  
  return { markdown: replaced, callouts };
}

markdownToHtml後にHTMLに復元

TypeScript
function restoreCallouts(html: string, callouts: Map<string, CalloutData>): string {
  let result = html;
  
  callouts.forEach((data, placeholder) => {
    // プレースホルダーがpタグで囲まれている場合も考慮
    const regex = new RegExp(`(<p>)?${placeholder}(</p>)?`, 'g');
    const calloutHtml = `
      <div class="callout callout-${data.color}">
        <span class="callout-icon">${data.icon}</span>
        <div class="callout-content">${data.content}</div>
      </div>`;
    result = result.replace(regex, calloutHtml);
  });
  
  return result;
}

全体の処理フロー

TypeScript
// ページコンポーネントでの使用
const markdownBody = blocksToMarkdown(blocks);

// Step 1: calloutをプレースホルダーに置換
const { markdown, callouts } = replaceCalloutsWithPlaceholders(markdownBody);

// Step 2: Markdown → HTML変換
const htmlContent = markdownToHtml(markdown);

// Step 3: プレースホルダーをcallout HTMLに復元
const finalHtml = restoreCallouts(htmlContent, callouts);

5. CSS: カテゴリ別カラーとダークモード対応

基本スタイル

CSS
.callout {
  display: flex;
  gap: 12px;
  padding: 16px 20px;
  border-radius: 8px;
  margin: 16px 0;
  border-left: 4px solid;
}

.callout-icon {
  font-size: 1.4em;
  flex-shrink: 0;
}

.callout-content {
  flex: 1;
  line-height: 1.7;
}

Notionカラー全色定義

CSS
/* ブルー系 */
.callout-blue_background {
  background-color: #e7f3fe;
  border-left-color: #2196f3;
}

/* グリーン系 */
.callout-green_background {
  background-color: #e8f5e9;
  border-left-color: #4caf50;
}

/* イエロー系 */
.callout-yellow_background {
  background-color: #fff8e1;
  border-left-color: #ff9800;
}

/* レッド系 */
.callout-red_background {
  background-color: #ffebee;
  border-left-color: #f44336;
}

/* パープル系 */
.callout-purple_background {
  background-color: #f3e5f5;
  border-left-color: #9c27b0;
}

/* ピンク系 */
.callout-pink_background {
  background-color: #fce4ec;
  border-left-color: #e91e63;
}

/* オレンジ系 */
.callout-orange_background {
  background-color: #fff3e0;
  border-left-color: #ff5722;
}

/* グレー系(デフォルト) */
.callout-gray_background,
.callout-default {
  background-color: #f5f5f5;
  border-left-color: #9e9e9e;
}

ダークモード対応

CSS
[data-theme='dark'] .callout-blue_background {
  background-color: #1a2733;
  border-left-color: #64b5f6;
}

[data-theme='dark'] .callout-green_background {
  background-color: #1a2e1a;
  border-left-color: #81c784;
}

[data-theme='dark'] .callout-yellow_background {
  background-color: #2e2a1a;
  border-left-color: #ffb74d;
}

[data-theme='dark'] .callout-red_background {
  background-color: #2e1a1a;
  border-left-color: #e57373;
}

[data-theme='dark'] .callout-purple_background {
  background-color: #261a2e;
  border-left-color: #ba68c8;
}

[data-theme='dark'] .callout-pink_background {
  background-color: #2e1a22;
  border-left-color: #f06292;
}

[data-theme='dark'] .callout-orange_background {
  background-color: #2e221a;
  border-left-color: #ff8a65;
}

[data-theme='dark'] .callout-gray_background,
[data-theme='dark'] .callout-default {
  background-color: #2a2a2a;
  border-left-color: #757575;
}

6. quoteブロックもcallout UIで統一

Notionのquote(引用)ブロックもcalloutと同じUIスタイルで表示することで、見た目を統一。

TypeScript
case 'quote': {
  const text = richTextToMarkdown(block.quote.rich_text);
  const color = block.quote.color || 'default';
  // quoteもcalloutマーカーで出力(アイコンは引用符)
  return `:::callout{icon="💬" color="${color}"}\n${text}\n:::`;
}

Tips

Tip 1: markdownToHtml内でHTMLタグが消える(エスケープされる)ため、プレースホルダー方式が必須。直接HTMLを埋め込む方法はmarkdown-itの設定次第で動かないことがある

Tip 2: Notionのcalloutカラーは {色}_background 形式(例: green_background)。_backgroundなしのカラー名はテキスト色を指し、背景色とは別物

Tip 3: callout内にインラインコード( )や太字()が含まれる場合、content`をmarkdownToHtmlで別途変換すると見栄えが良くなる

Tip 4: CSSの色値はNotionの公式カラーに合わせるとユーザーの期待通りの表示になる。Notion Web版のDevToolsで実際のRGB値を確認するのが確実

まとめ

  • Notion Blocks → Markdown変換ではcalloutの情報が失われるのが根本的な問題
  • :::calloutマーカー形式でMarkdownに出力し、プレースホルダー方式でHTML変換を通す
  • CSSでNotionの全8色 + ダークモードに対応し、左ボーダーラインで視覚的に区別
  • quoteブロックもcallout UIに統一することで、記事全体のデザインに一貫性を持たせる
  • プレースホルダー方式は他のカスタムブロック(embed, bookmark等)にも応用可能
メモ
後日談:見た目はその後変えました。この記事では「左に 4px のカラーバー + 淡い背景色」にしていましたが、記事中に callout が並ぶと左端の色帯が強すぎて本文を分断するように見えました。現在は左バーをやめて全周 1px のボーダーにし、背景色ももっと低彩度に落としています。仕組み(プレースホルダー方式)はそのままで、CSS だけを差し替えています。変換ロジックと見た目を分離しておくと、こういう調整が楽になります。

参考リンク

更新履歴

  1. 後日談を追加(左 4px バーのデザインが本文を分断して見えたため、全周 1px ボーダーに変更したこと。変換ロジックはそのまま)。壊れていた目次マーカーを自動目次に差し替え。意味が通っていなかった Tip 3 を書き直し。引用形式の Tips を callout に統一。コードブロックの言語指定とファイルパスのキャプションを整理。参考リンクの節を新設。箸条書きだった「はじめに」を文章に整理。

この記事のタグ