Liquid Snippets by ALSEL
UI部品中級

画像上のツールチップ表示

画像上に番号付きボタンを配置し、クリック時にツールチップを開閉するセクション。レスポンシブ対応で、モバイルではリスト、デスクトップでは画像上の絶対位置に吹き出しを表示する。

用途
商品画像や図解の特定箇所に説明を付けたいときに使用。「この部分について詳しく説明」といった情報提示が必要な場面で活躍する。
設置場所
sections/tooltips.liquid として配置し、テーマカスタマイザーでセクションを追加。ブロック内で「タイトル」「top(上位置)」「left(左位置)」を%単位で設定して複数のツールチップを配置する。
注意点
画像の縦横比が異なる場合、top の値は aspect_ratio で自動調整されるため、元画像と同じアスペクト比の画像を使うことが重要。デスクトップでのツールチップ位置(top・left)は画像サイズ100%を基準とした相対値なので、レスポンシブでの表示位置ズレを防ぐには必ずプレビューで確認する。JavaScript を無効にした環境では代替として番号付きリスト表示になるが、aria-expanded の状態管理には後続の JS ファイルが必須となる。
タグ:tooltipimage-hotspotinteractiveresponsiveaccessibility

コード

452 行 / liquid
{%- comment -%} ---------------- THE CSS --------------------- {%- endcomment -%}

{%- assign button_width_small = 28 -%}
{%- assign button_width_large = 32 -%}
{%- assign tooltip_max_width = 320 -%}
{%- assign image_aspect_ratio = section.settings.image.aspect_ratio | default: 1 -%}
{%- assign section_selector = '[data-tooltips="' | append: section.id | append: '"]'-%}

<style>
  .tooltips-section {
    position: relative;
  }
  
  .tooltips-figure {
    margin: 0;
  }
  
  .tooltips-img {
    display: block;
    width: 100%;
  }
  
  .tooltips-list {
    padding: 0 0 0 32px;
    list-style: decimal;
  }
  
  .tooltip-item {
    box-sizing: border-box;
    padding: 8px 12px;
  }
  
  .tooltip-button {
    background: transparent;
    width: 100%;
    border: 0;
    padding: 0;
    text-align: left;
    z-index: 1;
  }

  .tooltip-button:focus {
    outline: none;
  }
  
  .tooltip-index {
    border-radius: 50%;
    text-align: center;
    position: absolute;
    padding: 0;
    font-size: 14px;
    -webkit-transform: translate(-50%, -50%);
    transform: translate(-50%, -50%);
    line-height: {{ button_width_small }}px;
    width: {{ button_width_small }}px;
  }

  .tooltip-overlay {
    background: #fff;
    opacity: 1;
    overflow: hidden;
    will-change: height;
  }
  
  .tooltip-header {
    display: none;
  }
  
  {{ section_selector }} .tooltip-button:focus .tooltip-index,
  {{ section_selector }} .tooltip-button[aria-expanded="true"] .tooltip-index {
    box-shadow: 0 0 0 2px {{ section.settings.tooltip_focus_color }};
  }
  
  {{ section_selector }} .tooltip-index {
    background-color: {{ section.settings.tooltip_background_color }};
    color: {{ section.settings.tooltip_color }};
  }
  
  {%- for block in section.blocks -%}
  {{ section_selector }} .tooltip-item:nth-child({{ forloop.index }}) .tooltip-index {
    top: 0px;
    margin-top: {{ block.settings.top | divided_by: image_aspect_ratio }}%;
    left: {{ block.settings.left }}%;
  }
  {%- endfor -%}
  
  .tooltip-item .tooltip-overlay:empty {
    animation: none;
  }
  
  [aria-expanded="true"] ~ .tooltip-overlay {
    animation: tooltip-expand 200ms both cubic-bezier(0.4, 0, 0.2, 1);
  }
  
  [aria-expanded="false"] ~ .tooltip-overlay {
    animation: tooltip-collapse 180ms both cubic-bezier(0.4, 0, 0.2, 1);
  }
  
  @keyframes tooltip-expand {
    0% { height: var(--start-h) }
    100% { height: var(--end-h) }
  }
  
  @keyframes tooltip-collapse {
    0% { height: var(--start-h) }
    100% { height: var(--end-h) }
  }

  @media (prefers-reduced-motion: reduce) {
    .tooltip-overlay {
      animation-duration: 0s!important;
    }
  }
  
  @media (min-width: {{ section.settings.breakpoint }}px) {
    .tooltips-list {
      margin: 0;
      padding: 0;
      list-style: none;
    }
    
    .tooltip-button .tooltip-index {
      font-size: 16px;
      position: static;
      transform: translate(0, 0);
      line-height: {{ button_width_large }}px;
      width: {{ button_width_large }}px;
    }
    
    .tooltip-button {
      position: absolute;
      padding: 0;
      font-size: 16px;
      -webkit-transform: translate(-50%, -50%);
      transform: translate(-50%, -50%);
      width: {{ button_width_large }}px;
      height: {{ button_width_large }}px;
    }
    
    .tooltip-title {
      display: none;
    }
    
    .tooltip-item {
      max-width: {{ tooltip_max_width }}px;
      padding: 0;
    }
    
    [aria-expanded="true"] ~ .tooltip-overlay {
      margin-left: -{{ button_width_large }}px;
      z-index: 3;
    }
    
    [aria-expanded="true"].tooltip-button {
      z-index: 4;
    }

    .tooltip-overlay:empty {
      padding: 0;
      opacity: 0;
      transform: scale(0, 0);
    }
    
    .tooltip-overlay {
      max-width: 320px;
      padding: 16px 16px 16px 64px;
      margin-left: -32px;
      border-radius: 2px;
      box-shadow: 0 17px 50px 0 rgba(0,0,0,0.19);
      transform-origin: top left;
      position: absolute;
      will-change: opacity, transform;
    }
    
    .tooltip-header {
      display: block;
    }
    
    [aria-expanded="true"] ~ .tooltip-overlay {
      animation: tooltip-expand-large 200ms both cubic-bezier(0.4, 0, 0.2, 1);
    }

    [aria-expanded="false"] ~ .tooltip-overlay {
      animation: tooltip-collapse-large 180ms both cubic-bezier(0.4, 0, 0.2, 1);
    }
    
    @keyframes tooltip-expand-large {
      0% { opacity: 0; transform: scale(.2, .2); }
      100% { opacity: 1; transform: scale(1, 1); }
    }
    
    @keyframes tooltip-collapse-large {
      0% { opacity: 1; }
      100% { opacity: 0; }
    }
    
    {%- for block in section.blocks -%}
      {%- assign y = block.settings.top | divided_by: image_aspect_ratio -%}
      {%- assign tooltip_selector = '#tooltip-' | append: block.id -%}

      {{ tooltip_selector }} .tooltip-button {
        top: 0px;
        margin-top: {{ y }}%;
        left: {{ block.settings.left }}%;
      }

      {{ tooltip_selector }} .tooltip-overlay {
        top: -{{ button_width_large }}px;
        margin-top: {{ y }}%;
        left: {{ block.settings.left }}%;
      }

      {{ tooltip_selector }} .tooltip-button .tooltip-index {
        margin-top: 0;
      }
    {%- endfor -%}
 
  }
</style>


{%- comment -%} ---------------- THE NO-JS ------------------- {%- endcomment -%}

<noscript>
  <style>
    .tooltips-section .tooltips-list {
      list-style: decimal;
      padding: 24px;
    }

    .tooltip-item {
      position: static;
      padding: 16px;
      max-width: none;
    }
  </style>
</noscript>


{%- comment -%} ---------------- THE MARKUP ------------------ {%- endcomment -%}

<div class="tooltips-section" data-tooltips="{{ section.id }}">
  
  <figure class="tooltips-figure">
    {%- if section.settings.image == blank -%}
      {{ 'image' | placeholder_svg_tag: 'tooltips-picture' }}
    {%- else -%}
    <picture class="tooltips-picture">
      <source srcset="{{ section.settings.image | img_url: '320x' }} 1x,
                      {{ section.settings.image | img_url: '320x', scale: 2 }} 2x" media="(max-width: 320px)">
      <source srcset="{{ section.settings.image | img_url: '420x' }} 1x,
                      {{ section.settings.image | img_url: '420x', scale: 2 }} 2x" media="(max-width: 420px)">
      <source srcset="{{ section.settings.image | img_url: '768x' }} 1x,
                      {{ section.settings.image | img_url: '768x', scale: 2 }} 2x" media="(max-width: 768px)">
      <source srcset="{{ section.settings.image | img_url: '1240x' }} 1x,
                      {{ section.settings.image | img_url: '1240x', scale: 2 }} 2x" media="(max-width: 1240px)">
      <source srcset="{{ section.settings.image | img_url: '1440x' }} 1x,
                      {{ section.settings.image | img_url: '1440x', scale: 2 }} 2x" media="(max-width: 1440px)">
      <source srcset="{{ section.settings.image | img_url: '1660x' }} 1x,
                      {{ section.settings.image | img_url: '1660x', scale: 2 }} 2x" media="(max-width: 1660px)">
      <source srcset="{{ section.settings.image | img_url: '2048x' }} 1x,
                      {{ section.settings.image | img_url: '2048x', scale: 2 }} 2x" media="(min-width: 1661px)">
      <img class="tooltips-img" src="{{ section.settings.image | img_url: '2048x' }}" alt="{{ section.settings.image.alt }}">
    </picture>
    {%- endif -%}
  </figure>
  
  <ol class="tooltips-list" aria-label="{{ section.settings.title }}">
    {%- for block in section.blocks -%}
    <li class="tooltip-item" id="tooltip-{{ block.id }}">
      <button class="tooltip-button"
              type="button" 
              value="{{ block.id }}"
              aria-describedby="tooltip-overlay-{{ block.id }}" 
              aria-label="{{ forloop.index }}, {{ block.settings.title }}" 
              aria-expanded="false" 
              data-tooltip-trigger
              {{ block.shopify_attributes }} >
        <div class="tooltip-index" role="none">{{ forloop.index }}</div>
        <strong class="tooltip-title" role="none">{{ block.settings.title }}</strong>
      </button>
      <aside class="tooltip-overlay" id="tooltip-overlay-{{ block.id }}" data-tooltip-overlay></aside>
      <noscript data-tooltip-markup>
        <strong class="tooltip-header">{{ block.settings.title }}</strong>
        {{ block.settings.content }}
      </noscript>
    </li>
    {%- endfor -%}
  </ol>
  
  
{%- comment -%} ---------------- THE CONFIG ------------------ {%- endcomment -%}
  
  <script data-tooltips-config type="application/json">
   {
     "breakpoint": {{ section.settings.breakpoint }},
     "sectionId": {{ section.id | json }},
     "blocksId": {{ section.blocks | map: 'id' | json }}
   }
  </script>

</div>


{%- comment -%} ------------------ THE JS -------------------- {%- endcomment -%}

<script src="{{ 'tooltips.js' | asset_url }}" defer="defer"></script>


{%- comment -%} --------------- THEME EDITOR ----------------- {%- endcomment -%}

{%- if section.blocks.last.shopify_attributes != blank -%}
<script>
  (function ThemeEditor(SD){
    var sectionId = {{ section.id | json }};
    if (SD.ThemeEditor[sectionId]) return;
    
    SD.ThemeEditor[sectionId] = 'init';
    initEvents(sectionId);
    
    document.addEventListener('shopify:section:load', function(evt) {
      if (evt.detail.sectionId !== sectionId) return;
      
      var section = SD[sectionId];
      section.init(section.config);
      initEvents(sectionId);
    });

    function initEvents(sectionId) {
      var sectionHost = document.querySelector('[data-tooltips="' + sectionId + '"]');

      sectionHost.addEventListener('shopify:block:select', toggleSelect);
      sectionHost.addEventListener('shopify:block:deselect', toggleSelect); 
    }
    
    function toggleSelect(evt) {
      var sectionId = evt.detail.sectionId;
      var blockId = evt.detail.blockId;
      var section = SD[sectionId];
      
      evt.type === 'shopify:block:select'
      ? section.select(blockId)
      : section.deselect(blockId)
      
      updateBlocks(section, blockId);
    }
    
    function updateBlocks(section, blockId) {
      if (section.config.blocksId.indexOf(blockId) === -1) {
        section.config.blocksId.push(blockId);
      }
    }
 
  })(window.SectionsDesign = window.SectionsDesign || {ThemeEditor: []});
</script>
{%- endif -%}

{%- comment -%} ---------------- THE SETTINGS ---------------- {%- endcomment -%}

{% schema %}
{
  "name": "Tooltips",
  "class": "sd-tooltips",
  "settings": [
    {
      "type": "paragraph",
      "content": "Tooltips by Sections.design"
    },
    {
      "type": "image_picker",
      "id": "image",
      "label": "Main image"
    },
    {
      "type": "color",
      "id": "tooltip_color",
      "label": "Tooltip text color",
      "default": "#ffffff"
    },
    {
      "type": "color",
      "id": "tooltip_background_color",
      "label": "Tooltip background color",
      "default": "#64cbdf"
    },
    {
      "type": "color",
      "id": "tooltip_focus_color",
      "label": "Tooltip focus color",
      "default": "#ff0000"
    },
    {
      "type": "text",
      "id": "breakpoint",
      "label": "Media query breakpoint",
      "default": "768"
    }
  ],
  "blocks": [
    {
      "type": "Tooltip",
      "name": "Tooltip",
      "settings": [
        {
          "type": "text",
          "id": "title",
          "label": "Tooltip",
          "default": "Tooltip title"
        },
        {
          "type": "html",
          "id": "content",
          "label": "Tooltip HTML content",
          "default": "I am a tooltip, I provide additional explanatory content and showcase product features."
        },
        {
          "type": "range",
          "id": "top",
          "min": 0,
          "max": 100,
          "step": 1,
          "unit": "%",
          "label": "Top position",
          "default": 50
        },
        {
          "type": "range",
          "id": "left",
          "min": 0,
          "max": 100,
          "step": 1,
          "unit": "%",
          "label": "Left position",
          "default": 50
        }
      ]
    }
  ],
  "presets": [
    {
      "name": "Tooltips",
      "category": "Tooltips by Sections.design",
      "blocks": [
        {
          "type": "Tooltip"
        }
      ]
    }
  ]
}
{% endschema %}

出典・ライセンス

License:
MIT

このコードは mirceapiturca 著作の MIT ライセンスソースです。 原本の著作権は mirceapiturca が保有します。日本語訳は ALSEL によるものです。

関連項目

UI部品初級

成功チェックマークアイコン

緑色の円形背景にチェックマークを描いたSVGアイコン。フォームやメッセージの完了状態を視覚的に表現する。

📁 shopify-headless-theme·MIT·6
UI部品初級

エラーアイコン

エラー状態を示す円形アイコン。SVG で描画されたアラート記号を含む UI 部品で、accessibility 対応により視覚障害ユーザーには非表示。

📁 theme-tools·MIT·8
UI部品中級

カスタム要素のラッパーコンポーネント

child-element というカスタム HTML 要素をラップし、スロットに子要素を挿入するスニペット。Web Components パターンで再利用可能なコンポーネント構造を実現する。

📁 theme-tools·MIT·10
UI部品初級

静的コンテンツ

管理画面で入力したテキストコンテンツを静的に描画するブロック。セクション内で複数回使い分けられる汎用コンテナとして機能する。

📁 theme-tools·MIT·11
UI部品初級

テキスト

セクションに追加できるシンプルなテキストブロック。管理画面からドラッグ&ドロップで配置でき、固定テキスト「hello world」を表示する基本的なブロック実装。

📁 theme-tools·MIT·15
UI部品初級

セクション共通ヘッダー(タイトル・説明文)

セクション内で繰り返し使うタイトルと説明文をまとめて描画するスニペット。テーマカスタマイザーから設定したテキストとフォントサイズを条件付きで出力する。

📁 ks-bootshop·MIT·24