32
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

リアルな眼球を描く。HTMLとCSSだけで。 第2章 ~こいつ、動くぞッ!?~

Last updated at Posted at 2025-09-26

前回までのあらすじ

昔、「リアルな眼球を描く。HTMLとCSSだけで。」という記事を書きました。
当時の自分の知る限りのCSSテクニックを注ぎ込み、
納得できるだけの仕上がりの眼球ができたと思います。

しかし、この記事を書いてから早いもので6年ほどの月日が過ぎました。
その間にウェブ関係の技術革新は日進月歩で進み、
CSSも昔に比べるとさらに色々なことができるようになりました。

なので、昔だったら諦めていたこと、やり残したことに
今回改めて挑戦したいなと思ったわけです。

ええ、眼球グリグリ動かしたいなって。
そう思ったんです。

CSSのポテンシャルを見よ

今回ももちろんHTMLとCSSでマークアップしていきます。
svgやcanvas、jsは必要ありません。
前回はpugとstylusで記述しましたが、今回はpugとscssで記述していきます。

また基本的な部分は前回と変わらないので、
今回は要点を絞って説明していきたいと思います。

結果だけ知りたい人はこちら > eye pure css, without svg&canvas ver.2 (reactive)

全体的な構造

pug
.container
  label.open
    input.openCb(type='checkbox', checked=true)
    | full open
  ul.vision
    each _, i in Array(361)
      li.vision__item(class=[
        `vision__item--r${Math.floor(i / 19)}`,
        `vision__item--c${i % 19}`,
      ])
  .eye
    .eye__iris
      each i in [0, 1]
        ul.eye__ciliary(class=(i ===1)&&'eye__ciliary--sub')
          each n in Array(72)
            li.eye__ciliaryItem
    .eye__reflect
    .eye__reflect.eye__reflect--sub
    .eye__reflect.eye__reflect--tiny
  p.text touch this

Pugを各部分を紹介しながら全体の説明をしていきます。

まず、パーツとして大まかに
.vision部分と.eye部分に分けられています。

.vision部分はセンサーの役目を担っており、
タッチないしマウスオーバーに反応してCSSを変化させるためのものです。
361個のパネルを19x19の正方形に並べたものです。

.eyeは言わずもがな眼球の部分です。

.eye__irisは虹彩部分で中には.eye__ciliary.eye__ciliary--subを内包しています。
これらは毛様体を模しています。
前回は擬似要素や子要素を使って要素数を削減しながら実装しましたが、
今回は手っ取り早く最初から要素を用意します。

.eye__reflectは眼球に反射した光を表現するためのものです。
今回は3つ(前回は2つ)用意しました。

またこれらとは別に.open.textなども設定していますが、これらについては後々説明します。

センサー(ホバーを検知するグリッドアイテム)の仕組み


(センサーに色付けしてわかりやすくしたもの)

前提として、眼球はどれだけ動いてもシルエットは円のまま変わりません。
したがってセンサーに反応するのは虹彩部分だけで良いことになります。

ただ、「動いてる感」を演出するため、
眼球全体がホバーしている場所に寄るようにします。

このために4つのcssの変数(→カスタムプロパティ)を用意します。

  • --vxMove/--vyMove: 眼球全体がx/y方向に動く長さ
  • --vxAngle/--vyAngle: 虹彩がx/y軸で回転する角度

上で書いたようにセンサーは1辺が20pxの正方形で
19x19のタイル状に敷き詰められています。
何故19という中途半端な数かというと、
偶数にすると「真正面を見ている」という状況が作れなくなるからです。

このセンサーの一つひとつにホバーしたとき、変数を計算します。
センサー要素には「横何個目か」を示す.vision__item--cXXと、
「縦何個目か」を示す.vision__item--rXXがクラスとして振られているので、
scssの@forループを利用して記述します。

scss (該当部分のみ抽出)
$grid-count: 19;
$grid-center: floor($grid-count / 2);
.container {
  @for $i from 0 through ($grid-count - 1) {
    &:has(.vision__item--c#{$i}:hover) {
      --vxMove: #{($i - $grid-center) * 1px};
      --vxAngle: atan(#{($i - $grid-center) * 0.06});
    }
    &:has(.vision__item--r#{$i}:hover) {
      --vyMove: #{($i - $grid-center) * 1px};
      --vyAngle: atan(#{($grid-center - $i) * 0.06});
    }
  }
}

この結果はcssとしては以下になります。

css (該当部分のみ)
.container:has(.vision__item--c0:hover) {
  --vxMove: -9px;
  --vxAngle: atan(-0.54);
}
.container:has(.vision__item--r0:hover) {
  --vyMove: -9px;
  --vyAngle: atan(0.54);
}
.container:has(.vision__item--c1:hover) {
  --vxMove: -8px;
  --vxAngle: atan(-0.48);
}
.container:has(.vision__item--r1:hover) {
  --vyMove: -8px;
  --vyAngle: atan(0.48);
}
/* 以下省略 */

ポイントとしては:has()を用いて.containerに対して変数を定義していることです。
これによって.containerの子孫要素である.eye(眼球全体)や.eye__iris(虹彩)が
変更されたcss変数のスコープ内になります。

また角度はより実感に近づけるため、
逆三角関数のアークタンジェント(atan())を利用しています。
二等辺三角形の底辺を等分に分割し、その点から頂点に線を引いた時、
底辺の中心から離れるほどその角度は小さくなっていきますが、
逆三角関数を用いればそれを再現できます。

二つの変数は以下のように利用されます。

scss (該当部分のみ抽出)
$pers-depth: 90px;
.eye {
  transition: transform .25s ease;
  transform: translate(var(--vxMove), var(--vyMove));
  &__iris {
    transition: transform .25s ease;
    transform: rotateX(var(--vyAngle)) rotateY(var(--vxAngle)) translateZ($pers-depth);
  }
}

眼球の積層構造

今回はこのような順でレイヤー構造を置きました。

  1. 虹彩 .eye__iris
    1. 毛様体(大) .eye__ciliary
    2. 毛様体(小) .eye__ciliary--sub
    3. 瞳孔
  2. 眼球の陰影1 .eye::after
  3. 眼球の陰影2 .eye::before
  4. 反射 .eye__reflect

下に行くにつれて手前側(z軸の+方向)に積み重なっていく感じです。

眼球の陰影は周縁部分ほど強くかかっているので、
この影響を受けて、虹彩が動く際、円の外縁に近づくほど虹彩も暗くなります。
これがよりリアルに見える役目をしています。

また球体の場合、球体が回転した時、反射には全く影響がないはずですが、
本来は球体表面の僅かな歪みが反映されるため、
動くたびに反射が若干揺らぐものです。

これを表現するため、変数を用意し、奇数のセンサーにホバーした時のみ、
反射が僅かに動くようにしました。

scss (該当部分のみ抽出)
.container {
  &:has(.vision__item:nth-child(odd):hover) {
    --vRefAngle: .8deg;
  }
}

眼球全体と陰影を描く

scss
.eye {
  clip-path: $lidFull;
  #{$eyeClosed} & {
    clip-path: $lidHalf;
  }
  position: absolute;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: $sizeAll;
  height: $sizeAll;
  border-radius: 50%;
  background-color: #fff;
  perspective: $pers-distance;
  transition: transform .25s ease, scale .25s ease-out, clip-path .25s ease;
  transform: translate(var(--vxMove), var(--vyMove));
  scale: .8;
  .container:has(.vision:hover) & {
    scale: 1;
  }
  #{$eyeClosed}:has(.vision:hover) & {
    animation: eyeBlink 10s ease infinite backwards;
  }
  &::before,
  &::after {
    position: absolute;
    border-radius: 50%;
    content: "";
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
  }
  &::before {
    z-index: 3;
    box-shadow: inset 0 -30px 50px rgba(16, 16, 8, 0.8), inset 0 -5px 15px rgba(16, 16, 8, 0.8);
    mix-blend-mode: multiply;
  }
  &::after {
    z-index: 2;
    opacity: 0.75;
    background-image: radial-gradient(circle at 45% 45%, #ffe 30%, #222 70%);
    mix-blend-mode: color-burn;
  }
}

.eyeでは擬似要素で陰影を加えるとともに、前述の通り、センサーのホバーに応じて若干その方向に寄るようにしてあります。

また、ホバーと同時に眼球が近づいてくるという機能も付け加えられています。
起動時はscaleを0.8にして、ホバーしたときに1にすることで実現しています。

あれ?と思った人もいるかもしれませんが、
実はscaletranslaterotateなどが、
transformから独立して設定できようになりました。

これによってtransform関連の値がかなり自由に、かつわかりやすく設定できるようになったと思います。
cssカスタムプロパティも活用すれば、transformプロパティ自体を
@keyframesに設定するようなわかりにくい方法を使わなくて良くなりますね。

陰影の付け方は前回とほぼ変わっていないと思います。
mix-blend-modeの「multiply(乗算)」と「color-burn(焼き込みカラー)」を使って影が効果的になるように……こう、なんとなく深みが出る感じで調整します。

そう、フィーリングです。

虹彩を描く

毛様体を加えた状態
scss
.eye {
  &__iris {
    position: relative;
    z-index: 1;
    width: $sizeIris;
    height: $sizeIris;
    border-radius: 50%;
    background-image: radial-gradient(
      circle closest-side at center,
      #b86e29 45%,
      #94c7d4 55%,
      #94c7d4 65%,
      #58697C 94%,
      #fff 100%
    );
    transition: transform .25s ease;
    transform: rotateX(var(--vyAngle)) rotateY(var(--vxAngle)) translateZ($pers-depth);
    mix-blend-mode: multiply;
    &::before {
      @include absolute-center;
      content: "";
      z-index: 3;
      width: 36px;
      height: 36px;
      background: radial-gradient(circle at center, #000 50%, transparent 100%);
      border-radius: 50%;
      opacity: .8;
      filter: blur(3px);
      transition: transform 0.3s ease;
      .container:hover & {
        transform: translate(-50%, -50%) scale(0.8);
      }
    }
  }
}

虹彩(.eye__iris)の役目は大きく分けて二つ、センサーに応じて動くことと、また虹彩自体の基本的な見た目を決めることに貢献します。

動きに関しては前述した通りですが、z軸方向に押し出してから回転させることで、眼球全体が動いているように見せかけています。

見た目に関してはbackground-imageradial-gradient()を使って中心からグラデーションを描くことで表現しています。
中心から、茶色→薄い水色→水色→灰色っぽい水色→白色で遷移させています。
灰色っぽい水色から白色の部分は若干滲む感じの境界線にして透け感を演出しています。

::beforeは瞳孔部分で.containerにホバーしたタイミングでちょっとだけ収縮するようになってます。

毛様体を描く

毛様体を加えた状態
scss
.eye {
  &__ciliary {
    @include absolute-center;
    display: grid;
    width: 90%;
    height: 90%;
    z-index: 1;
    &--sub {
      z-index: 2;
      width: 55%;
      height: 55%;
    }
  }
  &__ciliaryItem {
    position: absolute;
    width: 100%;
    height: 100%;
    clip-path: polygon(
      50% 0,
      calc(50% + 2px) 50%,
      50% 100%,
      calc(50% - 2px) 50%
    );
    opacity: .3;
    background-color: $col-bk8;
    @for $num from 2 through 72 {
      &:nth-child(#{$num}) {
        transform: rotateZ(5deg * ($num - 1));
      }
    }
  }
}

この部分ついて、前回は要素を減らしてcssを増やすような記述をしていたのですが、
今回は要素数を増やす代わりにcssを削減するような書き方をしています。

具体的には要素を72個用意し、5度ずつ回転させることで
360度に対して放射状に細い菱形を配置しています。

前回と同様にこの毛様体をコピーし、色を濃くした上で瞳孔と同じサイズにして重ねてあります。

ポイントとしては、菱形の作成方法を変えたことです。
前回は正方形である要素を45度軸で潰すことで作成していましたが、
今回はclip-pathpolygon()を利用して菱形状をにくり抜くことで
cssを最小限で済ませるようにしました。

反射を加える

毛様体を加えた状態
scss
.eye {
  &__reflect {
    --yRef: 12deg;
    --xRef: -12deg;
    position: absolute;
    z-index: 4;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    opacity: .85;
    background-color: #fff;
    filter: blur(.5px);
    transition: transform .2s ease;
    transform: rotateX(calc(var(--vRefAngle) + var(--yRef))) rotateY(calc(var(--vRefAngle) + var(--xRef))) translateZ($pers-depth);
    &--sub {
      --yRef: 20deg;
      --xRef: -20deg;
      width: 75px;
      height: 75px;
      opacity: .2;
      filter: blur(1px);
      background-color: transparent;
      background-image: linear-gradient(
        -45deg,
        #fff,
        #fff0
      );
    }
    &--tiny {
      --yRef: 26deg;
      --xRef: -4deg;
      width: 8px;
      height: 8px;
      opacity: .9;
    }
  }
}

反射(.eye__reflect)は今回3種類加えました。
この3種類の反射はまずz軸手前方向に90px浮かせ、
中心からのx/y軸で回転させることで移動させています。
このため見た目としては楕円に歪んでおり、前回より自然な反射に見えると思います。

ここでも設定にはcssカスタムプロパティを使っています。
transformプロパティは.eye__reflectでしか設定されていませんが、
--yRef--xRefという変数を、
.eye__reflect.eye__reflect--sub.eye__reflect--tiny
それぞれで別々に設定することにより、transformの値が変化します。

以上………?

としても良かったのです。これでも十分にキモい素晴らしい出来上がりだと思ったのですが、
ちょっと思ったのです。

瞬きができたらもっとキ…可愛くなるのでは?

というわけで無謀な挑戦ですが、瞬きができるように更なる改変を加えたいと思います。

瞬きするためにはまぶたを閉じないといけない。
そのためにはまぶたを作らないといけない。

というわけで作った瞼が以下になります。

scss
$lidFull: path("M0,100s-10,100,100,100,100-100,100-100c0,0,10-100-100-100S0,100,0,100Z");
$lidHalf: path("M0,100s45,55,100,55,100-55,100-55c0,0-45-55-100-55S0,100,0,100Z");
$lidQuarter: path("M0,100s45,20,100,20,100-20,100-20c0,0-45-20-100-20S0,100,0,100Z");
$lidClose: path("M0,100s45,0,100,0,100-0,100-0c0,0-45-0-100-0S0,100,0,100Z");

順に説明しますと、$lidFullは全開、つまり普通の(普通ってなんだろう)眼球状態、
$lidHalfが普通に目を開いてる状態、$lidQuarterが半目の状態、
$lidCloseが目を閉じた状態です。

3次ベジェ曲線で描かれており、単純に菱形の上下2点に制御点を加え、
この上下2点と制御点を変えることで操作します。
アニメーションも作っておきます。

scss
@keyframes eyeBlink {
  0%, 2%, 4%, 100% {
    clip-path: $lidHalf;
  }
  1% {
    clip-path: $lidClose;
  }
  3% {
    clip-path: $lidQuarter;
  }
}

パチパチと瞬きする時一瞬半目を交えることで無駄にリアリティを上げております。
余談ですが、@keyframesの%部分は値が同じであれば
上記のように記述をまとめることができます。

下記が実際の反映部分です。

scss (該当部分のみ抽出)
$eyeClosed: '.container:has(.openCb:not(:checked))';

.eye {
  clip-path: $lidFull;
  #{$eyeClosed} & {
    clip-path: $lidHalf;
  }
  #{$eyeClosed}:has(.vision:hover) & {
    animation: eyeBlink 10s ease infinite backwards;
  }
}

瞼型のシェイプはclip-pathの値として使用します。
左上の「full open」のチェックを外すとに瞼がある状態になり、
ホバーすることで瞬きします。

実は「full open」状態でもclip-pathはかかっているわけですが、
その領域が眼球の円より外になるようにしてあるので影響を受けていないわけです。

最後に「touch this」のテキストを眼球ちゃんの下に付けました。
触ってもらわないと可愛さがわからないので…

これにて完成です。

See the Pen eye pure css, without svg&canvas ver.2 (reactive) by ichimonzi (@ichimonzi) on CodePen.

最後に

瞼をもっとリアルに(例えばちゃんと瞼らしく非対称な形に)することも考えましたが、そうすると閉じる時の挙動が制御しづらいので諦めました。あんまりそこら辺をいじると「もうSVG使ってるの一緒じゃね?」という気もしたので…。

虹彩の中への反射や、毛様体1本1本の差異なども表現できたらもっとリアルになるなと思いました。

でもHTMLとCSSでやることじゃなくね? と思いました。

みなさんも自分なりの眼球を作って可愛がってあげてください。

32
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?