スマホで hover 対応 ontouchstart タッチデバイス 判定

作成日:2022年2月28日

スマホなどのタッチデバイスで ontouchstart 属性や touchstart イベントなどを使ってマウスオーバー(ホバー)時の動作 :hover に対応させる方法やタッチデバイスの判定方法などについての覚書です。

参考サイト

タッチデバイスでのホバー

:hover は PC などのマウスを使うデバイスで、要素の上にマウスポインタ(カーソル)が乗っている状態に適用される疑似クラスですが、スマホなどのタッチデバイスでは動作が異なります。

CSS 意味(PC の場合)
:hover マウスポインタがその要素(が生成するブロック)の上に置かれているとき有効になる擬似クラス
:active ユーザーがマウスボタンを押して放すまでの間(要素をアクティブ化したとき)有効になる擬似クラス

スマホの場合、:hover を設定した要素では要素をタッチ(タップ)しても何も起きなかったり、タッチ後、適用されたスタイルが解除できなかったり、 違う要素がタッチされるまでスタイルが適用されたままになったりとデバイスやそのブラウザにより異なります。

以下は :hover と :active に背景色を transition を使って設定した例です。

<div class="sample">
  <p class="hover">hover</p>
  <p class="active">active</p>
</div>
.hover, .active {
  width: 100px;
  padding: .5rem 1rem;
  margin: 40px 0;
  border: 1px solid #ccc;
  text-align: center;
  transition: background-color .4s;
}
.hover:hover {
  background-color: #BFDDF6;
}
.active:active {
  background-color: #F9E0E0;
}

PC の場合、「hover」にマウスオーバーすると背景色が薄い青色に変わりマウスを外すと元に戻り、「active」をマウスボタンで押すとその間背景色がピンク色になります。

タッチデバイスでそれぞれの要素にタッチ(タップ)する場合の反応は、使用しているデバイスやブラウザにより異なります。

hover

active

以下のサンプルを開いて確認すると現時点(2022年2月)では、iPhone の Safari ではタッチしても hover と active のいずれにも反応しません(PC では上記同様に反応します)。

サンプル

ontouchstart 属性の追加

iPhone の Safari などのタッチデバイスで :hover や :active に反応するようにする1つの方法は、その要素やその親要素に値が空の ontouchstart 属性を指定します。

:hover や :active を適用させたい要素やその親要素に ontouchstart 属性を追加することで、:hover や :active を設定した要素にタッチするとイベントが発生して反応するようになります。

以下は body 要素に ontouchstart 属性を追加する例です。body 要素に指定しているので、配下のどの要素に :hover や :active を指定しても iOS などのタッチデバイスで反応するようになります。

※:hover の場合はタッチした後に他の要素をタッチするまで :hover で指定したスタイルが継続します。

以下の例の場合、他に :hover や :active を指定している要素がないので .wrapper や .sample の div 要素に指定しても同じことになります(必要に応じて .wrapper や .sample の div 要素に指定することで範囲を限定することができます)。

ontouchstart 属性の値は空なので、値は記述せずに単に ontouchstart としても同じです。

<body ontouchstart=""><!-- ontouchstart 属性を追加 -->
<div class="wrapper">
  <div class="sample">
    <p class="hover">hover</p>
    <p class="active">active</p>
  </div>
</div>
</body>

※ 但し、DOM 要素に ontouchstart 属性を記述すると Markup Validation Service でチェックすると「Error: Attribute ontouchstart not allowed on element body at this point.」のようなエラーとして表示されます。エラーと言っても特に問題ないと思いますが、気になる場合は JavaScript で追加します。

サンプル

以下は :hover を使って、画像にマウスオーバーするとキャプションをアニメーションで表示する例で、iOS Safari でも反応するように タッチデバイス用に body 要素に ontouchstart 属性を追加しています。

また、タッチした後、他の要素をタッチするまで、:hover で指定したスタイルが残ってしまうので、以下のサンプルではタッチして解除するための div 要素を配置しています。通常はページに他の要素があるので不要ですが、以下のサンプルでは他の要素がないのでスタイルを解除するためのタッチするスペース(empty クラスの要素)を入れてあります。

<body ontouchstart=""><!-- ontouchstart 属性を追加 -->
<div class="wrapper">
  <div class="sample">
    <div class="item">
      <div class="content"> 
          <img class="thumb" src="images/thumbs/01.jpg" alt="sample image 01"> 
          <div class="caption"><p>Title 1</p></div>
      </div>
    </div>
  </div>
  <div class="empty"></div><!-- 通常はこのようなスペースは不要 -->
</div>
</body>
.item {
  max-width: 240px;
}
.content {
  position: relative;
  overflow: hidden;
}
.thumb {
  max-width: 100%;
  transition: transform 0.3s;
}
.thumb:hover {
  transform: scale(1.1);
}
.caption {
  position: absolute;
  bottom: 0;
  text-align: center;
  width: 100%;
  height: 3rem;
  background-color: rgba(0,0,0,0.55);
  transform: translateY(3rem);
  transition: transform 0.3s;
  font-size: .875rem;
  color: #fff;
  pointer-events: none;
}
.item:hover .caption {
  transform: translateY(0px);
}
.empty {
  height: 300px;
  width: 100%;
}

サンプル

ontouchstart 属性を追加すると :hover には反応しますが PC のホバー時の動作と同じになるとは限りません(デバイスやブラウザにより動作は異なります)。

また、タッチのしかた(長くタッチしたままなど)によりデバイスの他のイベントが発生したりします。

タッチした際に PC のホバー時のような動作にするには、デバイスがタッチデバイスかどうかを判定してイベントを設定してスタイルを適用する方法もあります(デバイスにより処理を分ける)。

また、HTML に ontouchstart 属性を追加する場合、必要な全てのページに記述しなければならないので、次項の JavaScript を使って追加するほうが簡単で管理しやすいかと思います。

ontouchstart イベントハンドラ

ontouchstart は touchstart イベントのイベントハンドラです。以下は MDN の関連ページのリンクです。

以下は ontouchstart や touchstart をサポートしているブラウザです。現時点では ontouchstart は iOS や Android などでサポートされています。touchstart はモダンブラウザではサポートされていますが Safari や IE ではサポートされていません(タッチのイベントなので問題はありませんが)。

JavaScript で ontouchstart 属性を追加

前述の例では HTML で body 要素に ontouchstart 属性を記述して追加しましたが、以下のように JavaScript の setAttribute() を使って追加することもできます。

JavaScript
//空の ontouchstart 属性を body 要素に設定
document.getElementsByTagName('body')[0].setAttribute('ontouchstart', '');

上記では getElementsByTagName() で body 要素を取得していますが、以下でもほぼ同じです。

JavaScript
//querySelector() を使って body 要素を取得
document.querySelector('body').setAttribute('ontouchstart', '');

//document.body を使って body 要素を取得
document.body.setAttribute('ontouchstart', ''); 

上記を必要なページの共通の JavaScript に記述すれば、HTML で ontouchstart 属性を記述する必要がなく、body 要素から他の要素に変更する場合も簡単です。

例えば body 要素ではなく、ページに1つだけある .wrapper クラスを指定した要素に追加する場合は以下のように記述できます。

document.getElementsByClassName('wrapper')[0].setAttribute('ontouchstart', '');

サンプル

ホバーアニメーションサンプル

以下は、先述の例同様、transition でホバー時に画像を拡大し、キャプションを表示するアニメーションを設定している例です。

この例では画像をクリック(またはタップ)すると luminous lightbox を使ってライトボックス表示し、表示されるキャプション部分のリンクをクリック(またはタップ)するとページ内のアンカー(id 属性 link_target を指定した要素)に移動します。

サンプル

ontouchstart 属性を JavaScript で設定しているので HTML で属性を記述する必要はありません。また、ontouchstart 属性を追加する代わりに touchstart イベントを設定することもできます。

<body>
<div class="wrapper">
  <div class="grid">
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/01.jpg"> 
          <img class="thumb" src="images/thumbs/01.jpg" alt="sample image 01"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 1</a></h3>
        </div>
      </div>
    </div>
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/02.jpg"> 
          <img class="thumb" src="images/thumbs/02.jpg" alt="sample image 02"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 2</a></h3>
        </div>
      </div>
    </div>
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/03.jpg"> 
          <img class="thumb" src="images/thumbs/03.jpg" alt="sample image 03"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 3</a></h3>
        </div>
      </div>
    </div>
  </div>
  <div id="link_target">target</div><!-- アンカーを指定した要素 -->
</div>
<script src="../luminous/luminous.min.js"></script><!-- ライトボックスの読み込み --> 
<script>
//空の ontouchstart 属性を body 要素に設定
document.getElementsByTagName('body')[0].setAttribute('ontouchstart', '');
//以下でも同じ(または JavaScript で追加せずに HTML に ontouchstart 属性を追加しても同じ)
//document.body.addEventListener('touchstart', function(){});
 
//ライトボックスで img 要素の alt 属性の値をキャプションとして表示するためのオプション
const luminousOpts = {
  caption: (trigger) => {
    return trigger.querySelector('img').getAttribute('alt');
  },
}
//Luminous Lightbox の初期化
const luminousGalleryElems = document.querySelectorAll('.luminous');
if( luminousGalleryElems.length > 0 ) {
  new LuminousGallery(luminousGalleryElems, {}, luminousOpts);
} 
</script>
</body>

PC の場合、リンクにマウスオーバーすると文字色が変わりますが、タッチデバイスの場合、リンクにタッチすると色が変わると同時にリンク先に移動します。

また、デバイスやブラウザによってはタッチした際にアニメーションが期待通りに表示されない(例:iOS Chrome)など PC のホバー時と全く同じような動作にはなっていません。

*, *::before, *::after {
  box-sizing: border-box;
}
/*画像の下の余分なスペースができないように */
img {
  vertical-align: middle;
}
html {
  scroll-behavior: smooth;  /* iOS では機能しない */
}
.wrapper {
  padding: 1.5em;
  max-width: 960px;
  margin-right: auto;
  margin-left: auto;
}
.grid {
  display: grid;
  grid-gap: 1rem;
  gap: 1rem;
  grid-template-columns: repeat( auto-fill, minmax( 240px, 1fr ) );
}
.grid-content {
  position: relative;
  overflow: hidden;
}
.thumb {
  max-width: 100%;
  transition: transform 0.3s;
}
/*画像ホバー時に画像を拡大*/
.thumb:hover {
  transform: scale(1.1);
}
.caption {
  position: absolute;
  bottom: 0;
  text-align: center;
  width: 100%;
  height: 3rem;
  background-color: rgba(0,0,0,0.55);
  transform: translateY(3rem);
  transition: transform 0.3s;
  font-size: .875rem;
}
/*グリッドアイテムホバー時にキャプションを表示*/
.grid-item:hover .caption {
  transform: translateY(0px);
}
.link {
  color: #fff;
  transition: color 0.5s;
  text-decoration: none;
}
/*リンクホバー時にリンク色を変更*/
.link:hover {
  color: #0DD0F5;
}
#link_target {
  margin: 1500px 0;
}

/*ホバー時に画像の上に表示するアイコン*/ 
.icon-plus {
  opacity: 0;
  position: absolute;
  transition: opacity 0.3s;
  top: 50%; /* 水平垂直方向中央配置 */
  left: 50%; /* 水平垂直方向中央配置 */
  margin-right: -50%; /* 水平垂直方向中央配置 */
  transform: translate(-50%, -50%); /* 水平垂直方向中央配置 */
  pointer-events: none;
}
.grid-item:hover .icon-plus {
  opacity: 1;
}
/*アイコンを疑似要素で表示する SVG*/
.icon-plus::after{
  display: inline-block;  
  margin: 0;  
  content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='white' class='bi bi-plus-circle' viewBox='0 0 16 16'%3E  %3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E  %3Cpath d='M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z'/%3E%3C/svg%3E");
} 

/* luminous lightbox(ライトボックスのカスタマイズ設定) */
@media (max-width: 460px) {
  .lum-lightbox-inner img {
    max-width: 160vw;
    max-height: 90vh;
  }
  .lum-lightbox-caption {
    position: relative;
  }
  .lum-close-button {
    opacity: 0.7;
    background: rgba(0,0,0,.8);
    border-radius: 50%;
  }
}

関連ページ:マウスオーバー時のエフェクト

関連項目:ダブルタップ

touchstart イベントを設定

ontouchstart は touchstart イベントのイベントハンドラなので、ontouchstart 属性を追加する代わりに、touchstart イベントを設定することもできます。

以下は body 要素に addEventListener() を使って touchstart イベントを設定する例です。リスナー関数には何もしない空の関数を指定しています。

//空の touchstart イベントを設定
document.body.addEventListener('touchstart', ()=>{});

//または document.body.addEventListener('touchstart', function(){});

サンプル

touchstart と touchend

タッチデバイスでユーザーが画面(タッチ面)を指やスタイラスでタッチすると以下のようなイベントが発生します。

TouchEvent
イベント タイミング
touchstart タッチ面に触れたとき。イベントのターゲットはタッチした場所(タッチ点)の DOM 要素
touchend タッチ面から離れたとき。イベントのターゲットはタッチ点が要素の外に移動した場合でも、touchstart イベントのターゲットの DOM 要素
touchmove タッチしながら移動させたとき。イベントのターゲットは touchend 同様、タッチ点が要素の外に移動した場合でも、touchstart イベントのターゲットの DOM 要素
touchcancel 何らかの理由でタッチ個所が取り消されたとき。このイベントが発生する理由はデバイスごと、およびブラウザごとに異なる

touchstart と touchend イベントを使えば、ある要素にタッチしたときと離れたときのイベントを addEventListener() を使って設定することができます。

以下はタッチデバイスで bg-color-hover クラスを指定した要素にタッチすると transition で背景色を変更し、タッチが終了する(指を離す)と元の色に戻る例です。

タッチした際にはタッチイベントの他にマウスイベントやデバイスのイベントも発生してしまうので、14行目の e.preventDefault() でタッチイベント以外のデフォルトの動作を停止するようにしています。

※ 但し、e.preventDefault() でデフォルトの動作を停止するとリンクを設定している場合はクリックした際の動作(リンク先への移動)は機能しなくなります。

<div class="wrapper">
  <p class="touch-erea hover-bg">touch</p>
</div>

<script>
//hover-bg クラスを指定した要素を全て取得
const hoverBgElems = document.querySelectorAll('.hover-bg');
  
//hover-bg クラスを指定した全ての要素に touchstart と touchend イベントを設定
for(let i=0; i<hoverBgElems.length; i++) {
  //touchstart イベント
  hoverBgElems[i].addEventListener('touchstart', (e) => {
    //マウスイベントなどが送信されないようにデフォルトの動作を停止
    e.preventDefault();
    //hover-bg-js クラスを追加
    hoverBgElems[i].classList.add('hover-bg-js');
  });
  
  //touchend イベント
  hoverBgElems[i].addEventListener('touchend', () => {
    //hover-bg-js クラスを削除
    hoverBgElems[i].classList.remove('hover-bg-js');
  });
}
</script>

hover-bg クラスを指定した要素にタッチすると、背景色が hover-bg-js で指定した色に1秒かけて変わります。

/* transition で背景色を変更  */ 
.hover-bg {
  transition: background-color 1s;
}

/* JavaScript で追加するクラス  */ 
.hover-bg-js {
  background-color: #1FD453;
}
  
/* タッチする領域のスタイル  */ 
.touch-erea {
  width: 100px;
  padding: .5rem 1rem;
  margin: 40px 0;
  border: 1px solid #ccc;
  text-align: center;
}

このサンプルはタッチイベントのみを設定しているので、PC では何も起こりません。

要素をタッチすると背景色が変わり、離すと色が元に戻ります。但し、タッチしたまま要素の外に指を移動しても色は変更されたままで指を離すまで元に戻りません。

サンプル

タッチデバイスの判定

デバイスがタッチに対応しているかどうかを判定するにはいろいろな方法があるようですが、ユーザーエージェント(navigator.userAgent)によるデバイスの判定は推奨されていません。

参考:ユーザーエージェント文字列を用いたブラウザーの判定

以下のようなプロパティを調べることでタッチに対応しているデバイスかどうかを判定できるようです。

但し、orientation はタッチデバイスではなくても(例えば Chrome のインスペクタでレスポンシブ表示している場合など) true になります。

また orientation は現在では非推奨(Deprecated)になっているようですが、現時点ではほとんどのモバイル端末でサポートされています。

//window オブジェクトに ontouchstart が存在するか
const ontouchstartSupport = 'ontouchstart' in window;
  
//同時に対応できるタッチの最大数(タッチデバイスならば最低でも 1)
const maxTouchPointsSupport = navigator.maxTouchPoints > 0;
  
//window オブジェクトに orientation (スクリーンの向きを変える機能)が存在するか
const orientationSupport = 'orientation' in window;
//deprecated, but good fallback(非推奨)
 
//結果をアラート表示
alert(`ontouchstart: ${ontouchstartSupport}
maxTouchPoints: ${ontouchstartSupport}
orientation: ${orientationSupport}`);

サンプル

この例では、以下のように ontouchstart と maxTouchPoints の値によりタッチデバイスかどうかを判定することにしています。

//タッチデバイスかどうかの判定の値 
let hasTouchScreen = false;
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
}

//orientation が true のデバイスを加える場合
/*
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
}else if ('orientation' in window) {
  hasTouchScreen = true; 
}*/
  
//orientation が true も条件にする場合
/*
if('ontouchstart' in window && navigator.maxTouchPoints > 0 && 'orientation' in window) {
  hasTouchScreen = true;
}*/

デバイスにより処理を分ける

デバイスがタッチデバイスかどうかを判定して、その結果により異なる処理をすることができます。

以下はタッチデバイスの場合は、touchstart と touchend イベントを使い、タッチデバイス以外では mouseenter と mouseleave を使ってホバー時の効果を設定する例です。

touch

HTML と CSS は前述の例と同じです。

<p class="touch-erea hover-bg">touch</p>
.touch-erea {
  width: 100px;
  padding: .5rem 1rem;
  margin: 40px 0;
  border: 1px solid #ccc;
  text-align: center;
}
.hover-bg {
  transition: background-color 1s;
}
/* タッチまたはホバー時に適用するクラス */
.hover-bg-js {
  background-color: #1FD453;
}

タッチデバイスの場合は touchstart と touchend イベントを、PC などマウスを使うデバイスの場合は mouseenter と mouseleave イベントを使ってイベント発生時に適用するクラスを追加・削除しています。

//タッチデバイスかどうかの判定の値(初期値に false を設定)  
let hasTouchScreen = false;
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
}
  
//hover-bg クラスを指定した要素を全て取得
const hoverBgElems = document.querySelectorAll('.hover-bg');
  
//デバイスの判定結果を元に hover-bg クラスを指定した全ての要素にイベントを設定
for(let i=0; i<hoverBgElems.length; i++) {
  //タッチデバイスの場合は touchstart と touchend イベントを設定
  if(hasTouchScreen) {
    //touchstart イベント
    hoverBgElems[i].addEventListener('touchstart', (e) => {
      //マウスイベントなどが送信されないようにデフォルトの動作を停止
      e.preventDefault();
      //hover-bg-js クラスを追加
      hoverBgElems[i].classList.add('hover-bg-js');
    });
    //touchend イベント
    hoverBgElems[i].addEventListener('touchend', () => {
      //hover-bg-js クラスを削除
      hoverBgElems[i].classList.remove('hover-bg-js');
    });
  }else{ //タッチデバイス以外の場合
    //マウスが要素の上に入った時(または mouseover )
    hoverBgElems[i].addEventListener('mouseenter', (e) => {
      //hover-bg-js クラスを追加
      hoverBgElems[i].classList.add('hover-bg-js');
    });
    //マウスが要素から離れたとき(または mouseout )
    hoverBgElems[i].addEventListener('mouseleave', () => {
      //hover-bg-js クラスを削除
      hoverBgElems[i].classList.remove('hover-bg-js');
    });
  }
}

タッチデバイス以外の場合は、:hover の動作を mouseenter と mouseleave というマウスイベントで同じような動作になるようにしています。この例の場合は mouseover と mouseout を使っても同じですが、対象の要素に子要素がある場合は以下のような違いがあります。

イベント 説明
mouseenter/mouseleave バブルしないので、マウスが子要素に移動する場合はイベントは発生しません
mouseover/mouseout 親要素から子要素に移動する場合もイベントが発生します

サンプル(別ページで開く)

サンプル

以下は画像にマウスオーバーするとキャプションを表示して画像を拡大し、タッチデバイスの場合はタッチすると同様にキャプションを表示して画像を拡大する例です。

前述の例と同様、デバイスがタッチデバイスかどうかを判定して、タッチデバイスの場合は touchstart と touchend イベントを使い、PC などマウスを使うデバイスの場合は mouseenter と mouseleave イベントを使ってクラスを追加・削除しています。

※ iOS の Chrome の場合、transform の動きがカクカクしてしまいますが(バグ?)、Safari や Firefox では問題ありません。

sample image 01

Title 1

HTML
<div class="item">
  <div class="content"> 
    <img class="thumb" src="images/thumbs/01.jpg" alt="sample image 01">
    <div class="caption">
      <p>Title 1</p>
    </div>
  </div>
</div>
CSS
img {
  vertical-align: middle;
}
.item {
  max-width: 240px;
}
.content {
  position: relative;
  overflow: hidden;
}
.thumb {
  max-width: 100%;
  transition: transform 0.3s;
}
/* タッチまたはホバー時に適用するクラス */
.thumb-hover {
  transform: scale(1.1);
}
.caption {
  position: absolute;
  bottom: 0;
  text-align: center;
  width: 100%;
  height: 3rem;
  background-color: rgba(0,0,0,0.55);
  transform: translateY(3rem);
  transition: transform 0.3s;
  font-size: .875rem;
  color: #fff;
  pointer-events: none;
}
/* タッチまたはホバー時に適用するクラス */
.caption-hover {
  transform: translateY(0px);
}

画像(thumbs[i])の touchstart イベントで e.preventDefault() を指定しているので、タッチデバイスでは画像部分を触ってスクロールしようとしても、スクロールしません。

JavaScript
//タッチデバイスかどうかの判定の値(初期値に false を設定)  
let hasTouchScreen = false;
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
} 
//thumbs クラスを指定した要素を全て取得 
const thumbs = document.querySelectorAll('.thumb');
//caption クラスを指定した要素を全て取得 
const captions = document.querySelectorAll('.caption');
   
//デバイスの判定結果を元に要素にイベントを設定
for(let i=0; i<thumbs.length; i++) {
  //タッチデバイスの場合は touchstart と touchend イベントを設定
  if(hasTouchScreen) {
    thumbs[i].addEventListener('touchstart', (e) => {
      //マウスイベントなどが送信されないようにデフォルトの動作を停止
      e.preventDefault();
      //タッチしたら適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
    });
    //離れたらクラスを削除
    thumbs[i].addEventListener('touchend', () => {
      thumbs[i].classList.remove('thumb-hover');
      captions[i].classList.remove('caption-hover');
    });
  }else{ //タッチデバイス以外の場合は mouseenter と mouseleave イベントを設定
    thumbs[i].addEventListener('mouseenter', (e) => {
      //ホバー時に適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
    });
    thumbs[i].addEventListener('mouseleave', () => {
      //要素の外に出たらクラスを削除
      thumbs[i].classList.remove('thumb-hover');
      captions[i].classList.remove('caption-hover');
    });
  }
}

サンプル(別ページで開く)

以下は先述のホバーアニメーションサンプルをタッチデバイスかどうかを判定して処理するように書き換えたものです。

デバイスを判定して処理することで、デバイスにより異なる処理が可能になりますが、この例の場合、先述のホバーアニメーションサンプルのように ontouchstart 属性を設定するのとほとんど変わりません。

可能であれば、このようなホバー時にキャプションを表示してそのリンクをクリックするというような構造は、構造自体を検討した方が良いのかも知れません。

サンプル

HTML
<div class="wrapper">
  <div class="grid">
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/01.jpg"> 
          <img class="thumb" src="images/thumbs/01.jpg" alt="sample image 01"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 1</a></h3>
        </div>
      </div>
    </div>
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/02.jpg"> 
          <img class="thumb" src="images/thumbs/02.jpg" alt="sample image 02"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 2</a></h3>
        </div>
      </div>
    </div>
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/03.jpg"> 
          <img class="thumb" src="images/thumbs/03.jpg" alt="sample image 03"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 3</a></h3>
        </div>
      </div>
    </div>
  </div>
  <div id="link_target">target</div>
</div>
CSS
/*画像の下の余分なスペースができないように */
img {
  vertical-align: middle;
}
html {
  scroll-behavior: smooth;  /* iOS では機能しない */
}
.wrapper {
  padding: 1.5em;
  max-width: 960px;
  margin-right: auto;
  margin-left: auto;
}
.grid {
  display: grid;
  grid-gap: 1rem;
  gap: 1rem;
  grid-template-columns: repeat( auto-fill, minmax( 240px, 1fr ) );
}
.grid-content {
  position: relative;
  overflow: hidden;
}
.thumb {
  max-width: 100%;
  transition: transform 0.3s;
}
/*画像ホバーに(またはタッチ)時に画像を拡大(JavaScript で追加)*/
.thumb-hover {
  transform: scale(1.1);
}
.caption {
  position: absolute;
  bottom: 0;
  text-align: center;
  width: 100%;
  height: 3rem;
  background-color: rgba(0,0,0,0.55);
  transform: translateY(3rem);
  transition: transform 0.3s;
  font-size: .875rem;
}
/*ホバー(またはタッチ)時にキャプションを表示(JavaScript で追加)*/
.caption-hover{
  transform: translateY(0px);
}
.link {
  color: #fff;
  transition: color 0.5s;
  text-decoration: none;
}
/*キャプションにホバー(またはタッチ)時にリンク色を変更(JavaScript で追加)*/
.link-hover {
  color: #0DD0F5;
}
#link_target {
  margin: 1500px 0;
}
/*ホバー時に画像の上に表示するアイコン*/ 
.icon-plus {
  opacity: 0;
  position: absolute;
  transition: opacity 0.3s;
  top: 50%; /* 水平垂直方向中央配置 */
  left: 50%; /* 水平垂直方向中央配置 */
  margin-right: -50%; /* 水平垂直方向中央配置 */
  transform: translate(-50%, -50%); /* 水平垂直方向中央配置 */
  pointer-events: none;
}
/*ホバー(またはタッチ)時にアイコンを表示*/
.icon-plus-hover {
  opacity: 1;
}
/*アイコンを疑似要素で表示する SVG*/
.icon-plus::after{
  display: inline-block;  
  margin: 0;  
  content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='white' class='bi bi-plus-circle' viewBox='0 0 16 16'%3E  %3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E  %3Cpath d='M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z'/%3E%3C/svg%3E");
}  

/* luminous lightbox(ライトボックスのカスタマイズ設定) */
@media (max-width: 460px) {
  .lum-lightbox-inner img {
    max-width: 160vw;
    max-height: 90vh;
  }
  .lum-lightbox-caption {
    position: relative;
  }
  .lum-close-button {
    opacity: 0.7;
    background: rgba(0,0,0,.8);
    border-radius: 50%;
  }
}

この例の場合、グリッドアイテム(items[i])に touchstart イベントを設定しているので、preventDefault() を使ってデフォルトの動作を停止してしまうと、その配下の要素にも影響がおよび、ライトボックス表示やリンクへの移動ができないので、preventDefault() は使えません(画像に touchstart イベントを設定すれば可能)。そのため長押しするとデバイスのイベントなどが発生してしまいます。

PC の場合は、リンクホバー時にリンクの色を変えていますが、タッチデバイスの場合はその親要素のキャプション部分にタッチした際にリンクの色を変えるようにしています(リンクにタッチするとリンクへ移動してしまうため)。

また、前述の例とは異なり、タッチデバイスの場合、touchend イベントでキャプションのクラス(caption-hover)を削除すると、リンクが非表示になってしまうため touchend イベントではキャプションのクラス(caption-hover)は削除せず、キャプションを表示した状態にしておき、touchstart イベントの最初にキャプションのクラス(caption-hover)を削除するようにしています(26〜28行目)。

アイコンもキャプション同様の処理をしています。※このため、タッチデバイスの場合、アイコンもキャプションも最後に表示されたものはそのまま表示されたままになってしまうので setTimeout() を使って2秒後にキャプションとアイコンを表示するクラスを削除しています(45〜48行目)。

<script src="../luminous/luminous.min.js"></script><!-- ライトボックスの読み込み -->
<script>
//タッチデバイスかどうかの判定の値(初期値に false を設定)  
let hasTouchScreen = false;
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
} 
//grid-item クラスを指定した要素を全て取得 
const items = document.querySelectorAll('.grid-item');
//thumbs クラスを指定した要素を全て取得 
const thumbs = document.querySelectorAll('.thumb');
//caption クラスを指定した要素を全て取得 
const captions = document.querySelectorAll('.caption');
//link クラスを指定した要素を全て取得 
const links = document.querySelectorAll('.link');
   
//デバイスの判定結果を元に要素にイベントを設定
for(let i=0; i<thumbs.length; i++) {
  //タッチデバイスの場合は touchstart と touchend イベントを設定
  if(hasTouchScreen) {
    //グリッドアイテム部分(画像を含む全体)に touchstart イベントを設定
    items[i].addEventListener('touchstart', (e) => {
      //デフォルトの動作は停止しない(以下を削除またはコメントアウト)
      //e.preventDefault(); //不要
      //全ての caption-hover クラスと icon-plus-hover クラスを削除して状態を戻す
      captions.forEach((elem) => {
        elem.classList.remove('caption-hover');
      });
      icons.forEach((elem) => {
        elem.classList.remove('icon-plus-hover');
      });
      //タッチしたら適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //キャプション部分
    captions[i].addEventListener('touchstart', (e) => {
      links[i].classList.add('link-hover');
    });
    //離れたらクラスを削除
    items[i].addEventListener('touchend', () => {
      thumbs[i].classList.remove('thumb-hover');
      //2秒後にキャプションとアイコンを表示するクラスを削除
      setTimeout(()=> {
        captions[i].classList.remove('caption-hover');
        icons[i].classList.remove('icon-plus-hover');
      }, 2000);
    });
    //キャプション部分
    captions[i].addEventListener('touchend', (e) => {
      links[i].classList.remove('link-hover');
    });
  }else{ //タッチデバイス以外の場合は mouseenter と mouseleave イベントを設定
    items[i].addEventListener('mouseenter', (e) => {
      //ホバー時に適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseenter', (e) => {
      //リンクにマウスオーバーしたら色を変更
      links[i].classList.add('link-hover');
    });
    items[i].addEventListener('mouseleave', () => {
      //要素の外に出たらクラスを削除
      thumbs[i].classList.remove('thumb-hover');
      captions[i].classList.remove('caption-hover');
      icons[i].classList.remove('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseleave', (e) => {
      //リンクから外れたら色を戻す
      links[i].classList.remove('link-hover');
    });
  }
}

//ライトボックスで img 要素の alt 属性の値をキャプションとして表示するためのオプション
const luminousOpts = {
  caption: (trigger) => {
    return trigger.querySelector('img').getAttribute('alt');
  },
}
//Luminous Lightbox の初期化
const luminousGalleryElems = document.querySelectorAll('.luminous');
if( luminousGalleryElems.length > 0 ) {
  new LuminousGallery(luminousGalleryElems, {}, luminousOpts);
} 
</script>

画像部分にタッチイベントを設定

以下は画像部分に touchstart イベントを設定する例で preventDefault() でデフォルトの動作を停止し、画像ではなく、アイコンをタッチするとライトボックス表示するようにしています。

但し、画像部分に touchstart イベントで preventDefault() を設定しているので、タッチデバイスでは画像部分を触ってスクロールすることができません(アイコンやリンク部分、画像以外の部分を触ってスクロールすることはできます)。

サンプル

HTML ではライトボックスのリンク(a 要素)を画像ではなく、アイコンを囲むように変更しています。

<div class="wrapper">
  <div class="grid">
    <div class="grid-item">
      <div class="grid-content"> 
        <img class="thumb" src="images/thumbs/01.jpg" alt="sample image 01"> 
        <a class="luminous" href="images/01.jpg"> 
          <span class="icon-plus"></span>
        </a>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 1</a></h3>
        </div>
      </div>
    </div>
    <div class="grid-item">
      <div class="grid-content"> 
        <img class="thumb" src="images/thumbs/02.jpg" alt="sample image 02"> 
        <a class="luminous" href="images/02.jpg">
          <span class="icon-plus"></span>
        </a>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 2</a></h3>
        </div>
      </div>
    </div>
    <div class="grid-item">
      <div class="grid-content"> 
        <img class="thumb" src="images/thumbs/03.jpg" alt="sample image 03"> 
        <a class="luminous" href="images/03.jpg"> 
          <span class="icon-plus"></span>
        </a>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 3</a></h3>
        </div>
      </div>
    </div>
  </div>
  <div id="link_target">
    <p>target</p>
    <p><a href="#">to the top</a></p>
  </div>
</div>

JavaScript では画像部分に touchstart イベントを設定し、preventDefault() を指定しています。

//タッチデバイスかどうかの判定の値(初期値に false を設定)  
let hasTouchScreen = false;
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
} 
//grid-item クラスを指定した要素を全て取得 
const items = document.querySelectorAll('.grid-item');
//thumbs クラスを指定した要素を全て取得 
const thumbs = document.querySelectorAll('.thumb');
//caption クラスを指定した要素を全て取得 
const captions = document.querySelectorAll('.caption');
//link クラスを指定した要素を全て取得 
const links = document.querySelectorAll('.link');
//icon-plus クラスを指定した要素を全て取得 
const icons = document.querySelectorAll('.icon-plus');
//タップ後他の要素をタップしない場合にキャプションとアイコンを表示し続ける長さ(ミリ秒)
const beforeRemoveClass = 3000;
   
//デバイスの判定結果を元に要素にイベントを設定
for(let i=0; i<thumbs.length; i++) {
  //タッチデバイスの場合は touchstart と touchend イベントを設定
  if(hasTouchScreen) {
    //画像要素に touchstart イベントを設定
    thumbs[i].addEventListener('touchstart', (e) => {
      //デフォルトの動作を停止
      e.preventDefault();
      //全ての caption-hover クラスを一度削除
      captions.forEach((elem) => {
        elem.classList.remove('caption-hover');
      });
      icons.forEach((elem) => {
        elem.classList.remove('icon-plus-hover');
      });
      //タッチしたら適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //キャプション部分
    captions[i].addEventListener('touchstart', (e) => {
      links[i].classList.add('link-hover');
    });
    //離れたらクラスを削除
    items[i].addEventListener('touchend', () => {
      thumbs[i].classList.remove('thumb-hover');
      //3秒(beforeRemoveClass)後にキャプションとアイコン、リンクのホバー時のクラスを削除
      setTimeout(()=> {
        captions[i].classList.remove('caption-hover');
        icons[i].classList.remove('icon-plus-hover');
        links[i].classList.remove('link-hover');
      }, beforeRemoveClass);
    });
    //キャプション部分
    captions[i].addEventListener('touchend', (e) => {
      links[i].classList.remove('link-hover');
    });
  }else{ //タッチデバイス以外の場合は mouseenter と mouseleave イベントを設定
    items[i].addEventListener('mouseenter', (e) => {
      //ホバー時に適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseenter', (e) => {
      //リンクにマウスオーバーしたら色を変更
      links[i].classList.add('link-hover');
    });
    items[i].addEventListener('mouseleave', () => {
      //要素の外に出たらクラスを削除
      thumbs[i].classList.remove('thumb-hover');
      captions[i].classList.remove('caption-hover');
      icons[i].classList.remove('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseleave', (e) => {
      //リンクから外れたら色を戻す
      links[i].classList.remove('link-hover');
    });
  }
}

//ライトボックスで img 要素の alt 属性の値をキャプションとして表示するためのオプション
const luminousOpts = {
  caption: (trigger) => {
    //trigger(a 要素)の親要素を parentElement で取得して img 要素を取得
    return trigger.parentElement.getElementsByTagName('img')[0].getAttribute('alt');
  },
}
//Luminous Lightbox の初期化
const luminousGalleryElems = document.querySelectorAll('.luminous');
if( luminousGalleryElems.length > 0 ) {
  new LuminousGallery(luminousGalleryElems, {}, luminousOpts);
} 

CSS ではアイコンのサイズを大きくし、パディングを設定してタッチ(クリック)できる範囲を大きくしています。また、タッチデバイスでは画像以外の部分に触れてスクロールできるように画像の前後の間隔を広げています。

また、画像にタッチ(マウスオーバー)した際に、アイコンが目立つように画像を暗く表示するため、背景色を設定し、画像ホバー時に透明度を下げています。

*, *::before, *::after {
  box-sizing: border-box;
}
/*画像の下の余分なスペースができないように */
img {
  vertical-align: middle;
}
html {
  scroll-behavior: smooth;/*touch-action: manipulation;*/
}
.wrapper {
  padding: 1.5em;
  max-width: 960px;
  margin-right: auto;
  margin-left: auto;
}
.grid {
  display: grid;
  column-gap: 1rem;
  row-gap: 2rem;
  grid-template-columns: repeat( auto-fill, minmax( 240px, 1fr ) );
}
.grid-item {
}
.grid-content {
  position: relative;
  overflow: hidden;
  /*画像ホバー時に画像を半透明にして暗くするための背景*/
  background-color: #000;
}
.thumb {
  max-width: 100%;
  transition: transform 0.3s, opacity 0.3s;
}
/*画像ホバー(またはタッチ)時に画像を拡大及び半透明に(JavaScript で追加)*/
.thumb-hover {
  transform: scale(1.1); 
  opacity: 0.8;
}
.caption {
  position: absolute;
  bottom: 0;
  text-align: center;
  width: 100%;
  height: 3rem;
  background-color: rgba(0,0,0,0.25);
  transform: translateY(3rem);
  transition: transform 0.3s;
  font-size: .875rem;
}
/*ホバー(またはタッチ)時にキャプションを表示(JavaScript で追加)*/
.caption-hover {
  transform: translateY(0px);
}
.link {
  color: #fff;
  transition: color 0.5s;
  text-decoration: none;
}
/*キャプションホバー(またはタッチ)時にリンク色を変更(JavaScript で追加)*/
.link-hover {
  color: #0DD0F5;
}
#link_target {
  margin: 1500px 0;
  width: 100%;
  height: 300px;
  background-color: #01944A;
  text-align: center;
  padding: 50px;
  font-size: 20px;
  color: #fff;
}
#link_target a {
  color: #E9F4C5;
  font-size: 16px;
  text-decoration: none;
  padding: 5px 10px;
  border: 1px solid #fff;
}
/*ホバー時に画像の上に表示するアイコン*/ 
.icon-plus {
  opacity: 0;
  position: absolute;
  transition: opacity 0.3s;
  top: 50%;
  left: 50%;
  margin-right: -50%;
  transform: translate(-50%, -50%);/*pointer-events: none;*/
  /*タッチできる範囲を大きめに*/
  padding: 20px;
}
/*ホバー(またはタッチ)時にアイコンを表示*/
.icon-plus-hover {
  opacity: 1;
}
.icon-plus::after {
  display: inline-block;
  margin: 0;
  content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' fill='white' class='bi bi-plus-circle' viewBox='0 0 16 16'%3E  %3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E  %3Cpath d='M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z'/%3E%3C/svg%3E");
}

/* luminous lightbox(ライトボックスのカスタマイズ設定) */
@media (max-width: 460px) {
  .lum-lightbox-inner img {
    max-width: 160vw;
    max-height: 90vh;
  }
  .lum-lightbox-caption {
    position: relative;
  }
  .lum-close-button {
    opacity: 0.7;
    background: rgba(0,0,0,.8);
    border-radius: 50%;
  }
}
ダブルタップ

以下は、タップした場合はホバー時の動作を、ダブルタップした場合は、クリック時の動作をする例です。但し、ユーザがダブルタップすればクリック時の動作になるというのがわからないと使い勝手が悪いことになるかと思います。ダブルタップのメッセージを表示

また、preventDefault() を設定しているので、タッチデバイスでは画像部分を触ってスクロールすることができません。

サンプル

以下が JavaScript です。

18〜22行目:ダブルタップかどうかを判定するための変数を定義。

32〜42行目:ダブルタップの場合以外は、e.preventDefault() でデフォルトの動作を停止する記述。

その他の部分は同じです。

以下の場合、doubleTapThreshold で指定した500ミリ秒の間にタップを続けて行うとダブルタップと判定します。そのため、500ミリ秒以内に発生した2度めのタップはtapCount が1になるので e.preventDefault() は実行されないので、ライトボックスの表示やリンクの移動が行われます。

doubleTapThreshold の値を変更することで、どのぐらいの間隔でタップされるとダブルタップと判定するのかを調整できます。

また、タップ後に他の要素をタップしない場合はキャプションとアイコンを表示し続けるようになっていますが、beforeRemoveClass でその表示し続ける長さを調整することができます(前述の例では固定で2000ミリ秒としていました)。

//タッチデバイスかどうかの判定の値(初期値に false を設定)  
let hasTouchScreen = false;
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
} 
//grid-item クラスを指定した要素を全て取得 
const items = document.querySelectorAll('.grid-item');
//thumbs クラスを指定した要素を全て取得 
const thumbs = document.querySelectorAll('.thumb');
//caption クラスを指定した要素を全て取得 
const captions = document.querySelectorAll('.caption');
//link クラスを指定した要素を全て取得 
const links = document.querySelectorAll('.link');
//icon-plus クラスを指定した要素を全て取得 
const icons = document.querySelectorAll('.icon-plus');

//タップカウント(タップした回数)
let tapCount = 0;
//ダブルタップかどうかを判定するタップの間隔のミリ秒
const doubleTapThreshold = 500;
//タップ後他の要素をタップしない場合にキャプションとアイコンを表示し続ける長さ(ミリ秒)
const beforeRemoveClass = 5000;
   
//デバイスの判定結果を元に要素にイベントを設定
for(let i=0; i<thumbs.length; i++) {
  //タッチデバイスの場合は touchstart と touchend イベントを設定
  if(hasTouchScreen) {
    items[i].addEventListener('touchstart', (e) => {
      
      if (tapCount ===0) {
        // タップカウントを1増加
        ++tapCount;
        //doubleTapThreshold で指定した 500ms の間タップカウントを維持
        setTimeout(()=> {
          tapCount = 0;
        }, doubleTapThreshold);
        //デフォルトの動作を停止
        e.preventDefault();
      } else {
        //タップカウントをリセット
        tapCount = 0;
      }
      
      //全ての caption-hover クラスを一度削除
      captions.forEach((elem) => {
        elem.classList.remove('caption-hover');
      });
      icons.forEach((elem) => {
        elem.classList.remove('icon-plus-hover');
      });
      //タッチしたら適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //キャプション部分
    captions[i].addEventListener('touchstart', (e) => {
      links[i].classList.add('link-hover');
    });
    //離れたらクラスを削除
    items[i].addEventListener('touchend', () => {
      thumbs[i].classList.remove('thumb-hover');
      //5秒(beforeRemoveClass)後にキャプションとアイコンを表示するクラスを削除
      setTimeout(()=> {
        captions[i].classList.remove('caption-hover');
        icons[i].classList.remove('icon-plus-hover');
      }, beforeRemoveClass);
    });
    //キャプション部分
    captions[i].addEventListener('touchend', (e) => {
      links[i].classList.remove('link-hover');
    });
    //links[i].addEventListener('click' ()=>{});
  }else{ //タッチデバイス以外の場合は mouseenter と mouseleave イベントを設定
    items[i].addEventListener('mouseenter', (e) => {
      //ホバー時に適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseenter', (e) => {
      //リンクにマウスオーバーしたら色を変更
      links[i].classList.add('link-hover');
    });
    items[i].addEventListener('mouseleave', () => {
      //要素の外に出たらクラスを削除
      thumbs[i].classList.remove('thumb-hover');
      captions[i].classList.remove('caption-hover');
      icons[i].classList.remove('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseleave', (e) => {
      //リンクから外れたら色を戻す
      links[i].classList.remove('link-hover');
    });
  }
}

//ライトボックスで img 要素の alt 属性の値をキャプションとして表示するためのオプション
const luminousOpts = {
  caption: (trigger) => {
    return trigger.querySelector('img').getAttribute('alt');
  },
}
//Luminous Lightbox の初期化
const luminousGalleryElems = document.querySelectorAll('.luminous');
if( luminousGalleryElems.length > 0 ) {
  new LuminousGallery(luminousGalleryElems, {}, luminousOpts);
} 
<div class="wrapper">
  <div class="grid">
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/01.jpg"> 
          <img class="thumb" src="images/thumbs/01.jpg" alt="sample image 01"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 1</a></h3>
        </div>
      </div>
    </div>
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/02.jpg"> 
          <img class="thumb" src="images/thumbs/02.jpg" alt="sample image 02"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 2</a></h3>
        </div>
      </div>
    </div>
    <div class="grid-item">
      <div class="grid-content"> 
        <a class="luminous" href="images/03.jpg"> 
          <img class="thumb" src="images/thumbs/03.jpg" alt="sample image 03"> 
        </a>
        <span class="icon-plus"></span>
        <div class="caption">
          <h3 class="title"><a class="link" href="#link_target">Link 3</a></h3>
        </div>
      </div>
    </div>
  </div>
  <div id="link_target"><p>target</p><p><a href="#">to the top</a></p></div>
</div>
*, *::before, *::after {
  box-sizing: border-box;
}
/*画像の下の余分なスペースができないように */
img {
  vertical-align: middle;
}
html {
  scroll-behavior: smooth;
  /*touch-action: manipulation;*/
}
.wrapper {
  padding: 1.5em;
  max-width: 960px;
  margin-right: auto;
  margin-left: auto;
}
.grid {
  display: grid;
  column-gap: 1rem;
  row-gap: 2rem;
  grid-template-columns: repeat( auto-fill, minmax( 240px, 1fr ) );
}
.grid-item {
}
.grid-content {
  position: relative;
  overflow: hidden;
}
.thumb {
  max-width: 100%;
  transition: transform 0.3s;
}
/*画像ホバー(またはタッチ)時に画像を拡大(JavaScript で追加)*/
.thumb-hover {
  transform: scale(1.1);
}
.caption {
  position: absolute;
  bottom: 0;
  text-align: center;
  width: 100%;
  height: 3rem;
  background-color: rgba(0,0,0,0.55);
  transform: translateY(3rem);
  transition: transform 0.3s;
  font-size: .875rem;
}
/*ホバー(またはタッチ)時にキャプションを表示(JavaScript で追加)*/
.caption-hover{
  transform: translateY(0px);
}
.link {
  color: #fff;
  transition: color 0.5s;
  text-decoration: none;
}
/*キャプションホバー(またはタッチ)時にリンク色を変更(JavaScript で追加)*/
.link-hover {
  color: #0DD0F5;
}
#link_target {
  margin: 1500px 0;
  width: 100%;
  height: 300px;
  background-color: #01944A;
  text-align: center;
  padding: 50px;
  font-size: 20px;
  color: #fff;
}
#link_target a {
  color: #E9F4C5;
  font-size: 16px;
  text-decoration: none;
  padding: 5px 10px;
  border: 1px solid #fff;
} 
  
/*ホバー時に画像の上に表示するアイコン*/ 
.icon-plus {
  opacity: 0;
  position: absolute;
  transition: opacity 0.3s;
  top: 50%;
  left: 50%;
  margin-right: -50%;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
/*ホバー(またはタッチ)時にアイコンを表示*/
.icon-plus-hover {
  opacity: 1;
}
.icon-plus::after{
  display: inline-block;  
  margin: 0;  
  content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='white' class='bi bi-plus-circle' viewBox='0 0 16 16'%3E  %3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E  %3Cpath d='M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z'/%3E%3C/svg%3E");
}  
/* luminous lightbox(ライトボックスのカスタマイズ設定) */
@media (max-width: 460px) {
  .lum-lightbox-inner img {
    max-width: 160vw;
    max-height: 90vh;
  }
  .lum-lightbox-caption {
    position: relative;
  }
  .lum-close-button {
    opacity: 0.7;
    background: rgba(0,0,0,.8);
    border-radius: 50%;
  }
}
ダブルタップのメッセージを表示

前述の例の場合、ユーザがダブルタップすればクリック時の動作になるというのがわからない可能性があるので、以下は画像やリンクを最初にタップした場合にのみ「ダブルタップで拡大表示します」や「ダブルタップで移動します」のようなメッセージを一度だけアラート表示する例です(使い勝手がよいかどうかは別ですが)。

サンプル

HTML と CSS は前述の例と同じです。JavaScript には以下を追加しています。

24〜27行目:メッセージを表示したかどうかを判定するための変数を定義。

34〜50行目:初めて画像やリンクにタッチした場合にメッセージを表示する処理。addEventListener() の第3引数に {once: true} を指定して、呼び出しを一回のみに限定しています。また、全ての要素に設定しているので、メッセージを表示したかどうかを判定するための変数が false の場合のみアラート表示するようにしています。

その他の部分は同じです。

//タッチデバイスかどうかの判定の値(初期値に false を設定)  
let hasTouchScreen = false;
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
} 
//grid-item クラスを指定した要素を全て取得 
const items = document.querySelectorAll('.grid-item');
//thumbs クラスを指定した要素を全て取得 
const thumbs = document.querySelectorAll('.thumb');
//caption クラスを指定した要素を全て取得 
const captions = document.querySelectorAll('.caption');
//link クラスを指定した要素を全て取得 
const links = document.querySelectorAll('.link');
//icon-plus クラスを指定した要素を全て取得 
const icons = document.querySelectorAll('.icon-plus');

//タップカウント(タップした回数)
let tapCount = 0;
//ダブルタップかどうかを判定するタップの間隔のミリ秒
const doubleTapThreshold = 500;
//タップ後他の要素をタップしない場合にキャプションとアイコンを表示し続ける長さ(ミリ秒)
const beforeRemoveClass = 5000;

//画像拡大用のメッセージを表示したかどうかのフラグ
let thumbMessageShown = false;
//リンク移動用のメッセージを表示したかどうかのフラグ
let linkMessageShown = false;
   
//デバイスの判定結果を元に要素にイベントを設定
for(let i=0; i<thumbs.length; i++) {
  //タッチデバイスの場合は touchstart と touchend イベントを設定
  if(hasTouchScreen) {
    
    //画像部分をタッチした場合のイベント
    thumbs[i].addEventListener('touchstart', (e) => {
      e.preventDefault();
      if(!thumbMessageShown) {
        alert('ダブルタップで拡大表示します。');
      }
      thumbMessageShown = true;
    }, {once: true});
    
    //リンク部分をタッチした場合のイベント
    links[i].addEventListener('touchstart', (e) => {
      e.preventDefault();
      if(!linkMessageShown) {
        alert('ダブルタップで移動します。');
      }
      linkMessageShown = true;
    }, {once: true});
    
    //以降は前述の例と同じ
    items[i].addEventListener('touchstart', (e) => {
      if (tapCount ===0) {
        // タップカウントを1増加
        ++tapCount;
        //doubleTapThreshold で指定した 500ms の間タップカウントを維持
        setTimeout(()=> {
          tapCount = 0;
        }, doubleTapThreshold);
        //デフォルトの動作を停止
        e.preventDefault();
      } else {
        //タップカウントをリセット
        tapCount = 0;
      }
      //全ての caption-hover クラスを一度削除
      captions.forEach((elem) => {
        elem.classList.remove('caption-hover');
      });
      icons.forEach((elem) => {
        elem.classList.remove('icon-plus-hover');
      });
      //タッチしたら適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //キャプション部分
    captions[i].addEventListener('touchstart', (e) => {
      links[i].classList.add('link-hover');
    });
    //離れたらクラスを削除
    items[i].addEventListener('touchend', () => {
      thumbs[i].classList.remove('thumb-hover');
      //5秒(beforeRemoveClass)後にキャプションとアイコンを表示するクラスを削除
      setTimeout(()=> {
        captions[i].classList.remove('caption-hover');
        icons[i].classList.remove('icon-plus-hover');
      }, beforeRemoveClass);
    });
    //キャプション部分
    captions[i].addEventListener('touchend', (e) => {
      links[i].classList.remove('link-hover');
    });
  }else{ //タッチデバイス以外の場合は mouseenter と mouseleave イベントを設定
    items[i].addEventListener('mouseenter', (e) => {
      //ホバー時に適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseenter', (e) => {
      //リンクにマウスオーバーしたら色を変更
      links[i].classList.add('link-hover');
    });
    items[i].addEventListener('mouseleave', () => {
      //要素の外に出たらクラスを削除
      thumbs[i].classList.remove('thumb-hover');
      captions[i].classList.remove('caption-hover');
      icons[i].classList.remove('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseleave', (e) => {
      //リンクから外れたら色を戻す
      links[i].classList.remove('link-hover');
    });
  }
}

//ライトボックスで img 要素の alt 属性の値をキャプションとして表示するためのオプション
const luminousOpts = {
  caption: (trigger) => {
    return trigger.querySelector('img').getAttribute('alt');
  },
}
//Luminous Lightbox の初期化
const luminousGalleryElems = document.querySelectorAll('.luminous');
if( luminousGalleryElems.length > 0 ) {
  new LuminousGallery(luminousGalleryElems, {}, luminousOpts);
} 

以下はアラートの代わりに、要素を生成してメッセージを表示する例です。

サンプル

HTML は前述の例と同じです。

101〜125行目を追加。その他は前述と同じ
*, *::before, *::after {
  box-sizing: border-box;
}
/*画像の下の余分なスペースができないように */
img {
  vertical-align: middle;
}
html {
  scroll-behavior: smooth;
  /*touch-action: manipulation;*/
}
.wrapper {
  padding: 1.5em;
  max-width: 960px;
  margin-right: auto;
  margin-left: auto;
}
.grid {
  display: grid;
  grid-gap: 1rem;
  gap: 1rem;
  grid-template-columns: repeat( auto-fill, minmax( 240px, 1fr ) );
}
.grid-item {
}
.grid-content {
  position: relative;
  overflow: hidden;
}
.thumb {
  max-width: 100%;
  transition: transform 0.3s;
}
/*画像ホバー(またはタッチ)時に画像を拡大(JavaScript で追加)*/
.thumb-hover {
  transform: scale(1.1);
}
.caption {
  position: absolute;
  bottom: 0;
  text-align: center;
  width: 100%;
  height: 3rem;
  background-color: rgba(0,0,0,0.55);
  transform: translateY(3rem);
  transition: transform 0.3s;
  font-size: .875rem;
}
/*ホバー(またはタッチ)時にキャプションを表示(JavaScript で追加)*/
.caption-hover{
  transform: translateY(0px);
}
.link {
  color: #fff;
  transition: color 0.5s;
  text-decoration: none;
}
/*キャプションホバー(またはタッチ)時にリンク色を変更(JavaScript で追加)*/
.link-hover {
  color: #0DD0F5;
}
#link_target {
  margin: 1500px 0;
}
/*ホバー時に画像の上に表示するアイコン*/ 
.icon-plus {
  opacity: 0;
  position: absolute;
  transition: opacity 0.3s;
  top: 50%;
  left: 50%;
  margin-right: -50%;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
/*ホバー(またはタッチ)時にアイコンを表示*/
.icon-plus-hover {
  opacity: 1;
}
.icon-plus::after{
  display: inline-block;  
  margin: 0;  
  content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='white' class='bi bi-plus-circle' viewBox='0 0 16 16'%3E  %3Cpath d='M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z'/%3E  %3Cpath d='M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z'/%3E%3C/svg%3E");
}  
/* luminous lightbox(ライトボックスのカスタマイズ設定) */
@media (max-width: 460px) {
  .lum-lightbox-inner img {
    max-width: 160vw;
    max-height: 90vh;
  }
  .lum-lightbox-caption {
    position: relative;
  }
  .lum-close-button {
    opacity: 0.7;
    background: rgba(0,0,0,.8);
    border-radius: 50%;
  }
}
/* 追加部分(ここから) */
.thumbsMsg {
  color: #fff;
  background-color:  rgba(0,0,0,.65);
  padding: .5rem .75rem;
  font-size: 0.825rem;
  position: absolute;
  top: 50%;
  left: 50%;
  margin-right: -50%;
  transform: translate(-50%, -50%);
} 
.linksMsg {
  color: yellow;
  background-color:  rgba(0,0,0,.65);
  padding: .5rem .75rem;
  font-size: 0.825rem;
  position: absolute;
  top: 50%;
  left: 50%;
  margin-right: -50%;
  transform: translate(-50%, -50%);
} 
.link-highlight {
  color: yellow;
}
/*  追加部分(ここまで) */
17行目、26行目を追加し、アラート部分(43〜49と59〜67行目)を変更。その他は前述と同じ
//タッチデバイスかどうかの判定の値(初期値に false を設定)  
let hasTouchScreen = false;
if('ontouchstart' in window && navigator.maxTouchPoints > 0) {
  hasTouchScreen = true;
} 
//grid-item クラスを指定した要素を全て取得 
const items = document.querySelectorAll('.grid-item');
//thumbs クラスを指定した要素を全て取得 
const thumbs = document.querySelectorAll('.thumb');
//caption クラスを指定した要素を全て取得 
const captions = document.querySelectorAll('.caption');
//link クラスを指定した要素を全て取得 
const links = document.querySelectorAll('.link');
//icon-plus クラスを指定した要素を全て取得 
const icons = document.querySelectorAll('.icon-plus');
//(追加) grid-content クラスを指定した要素を全て取得
const contents = document.querySelectorAll('.grid-content');

//タップカウント(タップした回数)
let tapCount = 0;
//ダブルタップかどうかを判定するタップの間隔のミリ秒
const doubleTapThreshold = 500;
//タップ後他の要素をタップしない場合にキャプションとアイコンを表示し続ける長さ(ミリ秒)
const beforeRemoveClass = 5000;
//(追加) 画像部分やリンク部分を初めてタップした際に表示するメッセージの長さ(ミリ秒)
const msgDuration = 2000;

//画像拡大用のメッセージを表示したかどうかのフラグ
let thumbMessageShown = false;
//リンク移動用のメッセージを表示したかどうかのフラグ
let linkMessageShown = false;
   
//デバイスの判定結果を元に要素にイベントを設定
for(let i=0; i<thumbs.length; i++) {
  //タッチデバイスの場合は touchstart と touchend イベントを設定
  if(hasTouchScreen) {
    
    //画像部分をタッチした場合のイベント
    thumbs[i].addEventListener('touchstart', (e) => {
      e.preventDefault();
      if(!thumbMessageShown) {
        //(変更) アラートの代わりに以下でメッセージを表示
        const msgElem = document.createElement('div');
        msgElem.textContent = '画像をダブルタップで拡大表示します';
        msgElem.classList.add('thumbsMsg');
        contents[i].appendChild(msgElem);
        setTimeout(()=> {
          contents[i].removeChild(msgElem);
        }, msgDuration);
      }
      thumbMessageShown = true;
    }, {once: true});
    
    //リンク部分をタッチした場合のイベント
    links[i].addEventListener('touchstart', (e) => {
      e.preventDefault();
      if(!linkMessageShown) {
        //(変更) アラートの代わりに以下でメッセージを表示
        const msgElem = document.createElement('div');
        msgElem.textContent = 'リンクをダブルタップで移動します';
        msgElem.classList.add('linksMsg');
        contents[i].appendChild(msgElem);
        links[i].classList.add('link-highlight');
        setTimeout(()=> {
          contents[i].removeChild(msgElem);
          links[i].classList.remove('link-highlight');
        }, msgDuration);
      }
      linkMessageShown = true;
    }, {once: true});
    
    //以降は前述の例と同じ
    items[i].addEventListener('touchstart', (e) => {
      if (tapCount ===0) {
        // タップカウントを1増加
        ++tapCount;
        //doubleTapThreshold で指定した 500ms の間タップカウントを維持
        setTimeout(()=> {
          tapCount = 0;
        }, doubleTapThreshold);
        //デフォルトの動作を停止
        e.preventDefault();
      } else {
        //タップカウントをリセット
        tapCount = 0;
      }
      //全ての caption-hover クラスを一度削除
      captions.forEach((elem) => {
        elem.classList.remove('caption-hover');
      });
      icons.forEach((elem) => {
        elem.classList.remove('icon-plus-hover');
      });
      //タッチしたら適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //キャプション部分
    captions[i].addEventListener('touchstart', (e) => {
      links[i].classList.add('link-hover');
    });
    //離れたらクラスを削除
    items[i].addEventListener('touchend', () => {
      thumbs[i].classList.remove('thumb-hover');
      //5秒(beforeRemoveClass)後にキャプションとアイコンを表示するクラスを削除
      setTimeout(()=> {
        captions[i].classList.remove('caption-hover');
        icons[i].classList.remove('icon-plus-hover');
      }, beforeRemoveClass);
    });
    //キャプション部分
    captions[i].addEventListener('touchend', (e) => {
      links[i].classList.remove('link-hover');
    });
  }else{ //タッチデバイス以外の場合は mouseenter と mouseleave イベントを設定
    items[i].addEventListener('mouseenter', (e) => {
      //ホバー時に適用するクラスを追加
      thumbs[i].classList.add('thumb-hover');
      captions[i].classList.add('caption-hover');
      icons[i].classList.add('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseenter', (e) => {
      //リンクにマウスオーバーしたら色を変更
      links[i].classList.add('link-hover');
    });
    items[i].addEventListener('mouseleave', () => {
      //要素の外に出たらクラスを削除
      thumbs[i].classList.remove('thumb-hover');
      captions[i].classList.remove('caption-hover');
      icons[i].classList.remove('icon-plus-hover');
    });
    //リンクにイベント
    links[i].addEventListener('mouseleave', (e) => {
      //リンクから外れたら色を戻す
      links[i].classList.remove('link-hover');
    });
  }
}

//ライトボックスで img 要素の alt 属性の値をキャプションとして表示するためのオプション
const luminousOpts = {
  caption: (trigger) => {
    return trigger.querySelector('img').getAttribute('alt');
  },
}
//Luminous Lightbox の初期化
const luminousGalleryElems = document.querySelectorAll('.luminous');
if( luminousGalleryElems.length > 0 ) {
  new LuminousGallery(luminousGalleryElems, {}, luminousOpts);
}