GODRICH

【一行でコピペOK】CSS 枠線 アニメーション 9選

css 枠線 アニメーションは、カードや入力欄の外側の線だけを動かして「今なにが起きているか」を伝える技法です。今回は9種類を選び、光の縞を流すもの、SVGで線を描くもの、色相だけを回すものまで、マスク・SVG・アウトライン・@propertyと、それぞれ違う仕組みで組みました。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は派手さではなく、枠が何を知らせているかに沿っています。まだ何かが進行中だと伝える4つ(01〜04)を先に置き、続けて選択やフォーカスの場所を示す2つ(05・06)、ホバーで強調する1つ(07)、特別な瞬間を祝う1つ(08)を経て、最後はキーボード操作のためのフォーカスリング(09)で締めくくりました。各項目のコードは仕組みを決める部分だけを載せていて、セットアップまで含めた完成ファイルはzipにまとめてあります。

01斜めハイライトスイープ枠

枠の形だけに切り抜いた薄いリング(.frame::before)に斜めの縞グラデーションを敷き、background-positionを流し続けることで枠全体が一斉に光って流れているように見えます。AIが応答を生成している間の入力欄や、プレミアムなカードの枠に向いています。

mask-compositebackground-position2초
.frame::before {
  content: "";
  position: absolute;
  inset: 0;
  padding: 3px;
  background: repeating-linear-gradient(115deg, $color 0 10px, $subject-cream 10px 14px, transparent 14px 34px);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;   // 旧Safari向け
  mask-composite: exclude;       // 標準
}
.frame.is-demo::before,
.frame:focus-within::before {
  animation: sweep $duration $easing infinite;
}
@keyframes sweep { to { background-position: 220px 0; } }

02SVG枠線描画

pathLength="100"を指定したSVGのrectstroke-dasharrayで全周を100として登録し、stroke-dashoffsetを100から0へ減らすとペンで描くように枠がつながります。カードが読み込まれた瞬間に一度だけ発火する.is-drawnと、常時ループする.is-demoの2系統を同じ.card__rectに用意しました。

stroke-dashoffsetSVG0.6초
.card__rect {
  fill: none;
  stroke: $color;
  stroke-width: 3;
  stroke-dasharray: 100;   // pathLengthのおかげで周長と無関係に0〜100で扱える
  stroke-dashoffset: 100;
}
.card.is-drawn .card__rect { animation: draw $duration $easing forwards; }
.card.is-demo .card__rect { animation: draw-loop $dur-loop $easing infinite; }

@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes draw-loop {
  0%, 5%   { stroke-dashoffset: 100; }
  35%, 60% { stroke-dashoffset: 0; }
  100%     { stroke-dashoffset: -100; }
}

03破線が走る

上下左右の.frame__edgeを4つの独立した要素として置き、横の辺はbackground-positionを横方向、縦の辺は縦方向へ別々のkeyframesで動かすことで、破線ひとつながりが時計回りに進んでいるように見せています。アップロード中のカードや、順番待ちの状態表示に向いています。

repeating-linear-gradientbackground-position2초
.frame__edge--top, .frame__edge--bottom {
  height: 3px;
  background-image: repeating-linear-gradient(90deg, $color 0 8px, transparent 8px 16px);
}
.frame__edge--left, .frame__edge--right {
  width: 3px;
  background-image: repeating-linear-gradient(180deg, $color 0 8px, transparent 8px 16px);
}
.frame.is-demo .frame__edge--top    { animation: run-x $duration $easing infinite; }
.frame.is-demo .frame__edge--bottom { animation: run-x-rev $duration $easing infinite; }
.frame.is-demo .frame__edge--left   { animation: run-y-rev $duration $easing infinite; }
.frame.is-demo .frame__edge--right  { animation: run-y $duration $easing infinite; }
@keyframes run-x { to { background-position: 32px 0; } }
@keyframes run-y { to { background-position: 0 32px; } }

04グローパルス枠

@property<length>として型を登録した--glowbox-shadowのぼかし半径へ渡すと、0pxから16pxの間をコマ落ちなく補間でき、枠の光が滑らかに膨らんでは静まります。通知バッジやリアルタイムに更新されるステータスカードに向いています。

@propertybox-shadow2초
@property --glow {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
.badge {
  box-shadow: 0 0 var(--glow) 0 rgba($color, .55);
}
.badge.is-demo { animation: glow $duration $easing infinite; }

@keyframes glow {
  0%, 100% { --glow: 0px; }
  50%      { --glow: 16px; }
}

05角のブラケット

四隅の.frame__bracket--tlなどを角ごとに独立した要素として置き、ホバーや:focus-visibleでそれぞれの対角線方向へtranslateさせることで、カメラのフォーカスフレームのように四隅だけが開きます。画像ギャラリーで選んだ項目を示す枠に向いています。

transform: translate4개 요소0.3초
.frame__bracket--tl { top: -3px; left: -3px; border-right: 0; border-bottom: 0; }
.frame__bracket--br { bottom: -3px; right: -3px; border-left: 0; border-top: 0; }

.frame:hover .frame__bracket--tl,
.frame:focus-visible .frame__bracket--tl { transform: translate(-6px, -6px); }
.frame:hover .frame__bracket--br,
.frame:focus-visible .frame__bracket--br { transform: translate(6px, 6px); }

.frame.is-demo .frame__bracket--tl { animation: open-tl $dur-loop $easing infinite; }
@keyframes open-tl {
  0%, 15%  { transform: translate(0, 0); }
  50%, 70% { transform: translate(-6px, -6px); }
  100%     { transform: translate(0, 0); }
}

06下線が伸びる

入力欄の下の.field__barleft: 50%; right: 50%で中央に畳んでおき、フォーカス時に両方を0へ遷移させると中心から左右へ広がります。scaleXではなくleftrightを使ったのは、別途transform-originを指定しなくても中央基準の伸びが得られるからです。

left/right:focus0.22초
.field__bar {
  position: absolute;
  left: 50%; right: 50%; bottom: -2px;
  height: 2px;
  background: $color;
  transition: left $duration $easing, right $duration $easing;
}
.field__input:focus + .field__bar { left: 0; right: 0; }

.field.is-demo .field__bar { animation: grow $dur-loop $easing infinite; }
@keyframes grow {
  0%, 10%  { left: 50%; right: 50%; }
  40%, 60% { left: 0; right: 0; }
  100%     { left: 50%; right: 50%; }
}

07二重リング枠

box-shadowで作った内側の固定リングの外に、.btn::afterで二重目のリングを重ねておき、ホバーと:focus-visiblescaleを1.3まで広げながらopacityを0から1へ上げて波紋のように見せます。ボタンのホバー強調や通知アイコンの強調に向いています。

box-shadow 2겹transform: scale0.3초
.btn { box-shadow: 0 0 0 3px rgba($color, .9); }   // 内側の固定リング
.btn::after {
  content: "";
  position: absolute;
  inset: -3px;
  border: 2px solid $color;
  opacity: 0;
  transform: scale(1);
  transition: transform $duration $easing, opacity $duration $easing;
}
.btn:hover::after,
.btn:focus-visible::after { opacity: 1; transform: scale(1.3); }

08虹色の枠

border-image-sourceに固定の虹色linear-gradientを設定し、filter: hue-rotateの値だけを0degから360degへ回すことで、グラデーション自体は動かさずに枠の色だけがその場で巡回します。イベントバナーやお祝いカードの枠に向いています。

border-imagefilter: hue-rotate2초
.badge {
  border: 4px solid;
  border-image-slice: 1;
  border-image-source: linear-gradient(90deg, $error, $stage-orange, $stage-yellow, $subject-blue, $error);
}
.badge.is-demo { animation: hue $duration $easing infinite; }
@keyframes hue { to { filter: hue-rotate(360deg); } }

09フォーカスリングアニメーション

.field__inputoutlineを平常時は透明にしておき、:focus-visibleのときだけoutline-coloroutline-offset(0→4px)を同時に遷移させることで、リングが跳ねるように外へ広がって落ち着きます。マウスのクリックには反応せず、キーボードでのフォーカスだけに反応します。

outline-offset:focus-visible0.22초
.field__input {
  outline: 2px solid transparent;
  outline-offset: 0;
  transition: outline-color $duration $easing, outline-offset $duration $easing;
}
.field__input:focus-visible {
  outline-color: $color;
  outline-offset: 4px;
}

どこで崩れるか — 落とし穴

9つの中で01はmask-compositeを使って枠の形だけをくり抜いています。この値を接頭辞なしで書くとChrome・Edge 120以上、Safari 15.4以上、Firefox 53以上でしか効きません(MDNのmask-composite対応表)。それより古いChromeは-webkit-mask-composite: xorしか読まないため、標準のmask-composite: excludeだけを書くと最新ブラウザでの確認では問題なく見えても、古い環境では枠がくり抜かれずただ塗りつぶされた四角として表示されます。両方の宣言を並べて書いたのはそのためです。

もう一つ実際に組んでいて気づいたのは04の@propertyです。登録なしでカスタムプロパティだけをアニメーションさせると、ブラウザは開始値と終了値の間を補間できず、box-shadowのぼかし半径が中間の値を経ずに一段飛びで切り替わりました。<length>として型を宣言してからは0pxから16pxまでを滑らかに経由するようになり、この登録はChrome・Edge 85以上、Safari 16.4以上、Firefox 128以上で対応しています(MDNの@property)。ここまでの直しを反映した9ファイルを開くパスワードは4vruzyhzです。下のカードからzipを受け取ってください。

もう一つは08のfilterです。filterは要素の中身をまるごと合成し直す性質があるため、バッジの中に文字やアイコンを増やすとhue-rotateが一巡するたびにその領域全体が再描画されます。虹色の枠は小さなバッジのような要素にとどめ、カード全体や大きなコンテナには付けないほうが安全です。

アクセシビリティ

prefers-reduced-motion: reduceをすべての項目に用意しています。01・03・08は色や破線が流れる背景だけを止めて、模様はそのままの位置で静止します。02は線が描かれきった状態(stroke-dashoffset: 0)で止まり、04は--glowを8pxの中間値に固定して膨らみっぱなしにも消えっぱなしにもしません。05はループ用のanimationだけを外し、実際のホバーやフォーカスのtransitionはそのまま残るので、操作すれば角は今までどおり開きます。06はleftrightを0で固定して線が伸びきった状態を見せ、07はループ演出の外側リングだけを消して内側の固定リングだけを残します。09は例外で、transitionを切ってもキーボードフォーカス時にoutline-colorが変わる仕組み自体は生かしてあり、常時ループする見本用の状態も薄いリングが点いたままにしてあります。フォーカスリングは装飾ではなく「今どこにいるか」を伝える印だからです。

@media (prefers-reduced-motion: reduce) {
  .frame.is-demo::before,
  .frame.is-demo .frame__edge,
  .badge.is-demo,
  .frame.is-demo .frame__bracket,
  .field.is-demo .field__bar,
  .btn.is-demo::after { animation: none; }
}

このほか、枠線ではないCSSアニメーションはCSSカテゴリーに、サイトの紹介は紹介ページにまとめました。

FAQ

02のSVG枠線描画で、pathLengthを指定しないとどうなりますか?

pathLength="100"を外すと、stroke-dasharrayとstroke-dashoffsetの数値はSVGの実際のピクセル単位の周長を基準にすることになります。カードの大きさを変えるたびに周長も変わるので、100のままでは線が描き終わる前に止まったり、描き終わったあとも余分な線が残ったりします。カードの幅を固定しないなら、pathLengthで単位をそろえておくほうが安全です。

mask-compositeの代わりにborder-imageで同じ見た目は作れますか?

一部だけです。08のように色だけの固定グラデーションはborder-imageで組めますが、01のように背景を流し続けるアニメーションには対応する位置のプロパティがborder-imageにないため、mask-compositeかSVGを使うほうが向いています。

zipのReact版でも同じ動きになりますか?

はい、zipのreact/フォルダの各コンポーネントはdivやinput要素にこれまでと対応するクラス名を付けるだけで、色や時間はCSS変数としてpropsから渡されます。02もstroke-dashoffsetの値をJSXが計算するのではなく、durationとeasingをCSS変数として渡すだけで、ループするアニメーション自体は他の項目と同じ仕組みで動いています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。