03hoverなし端末専用の:active
@media (hover: none) and (pointer: coarse) でタッチ端末だけを判定し、そのときだけ :active の背景色を与える。マウス端末ではタップ後に色が張り付かない。
使いどころタップ後もグレー背景が残るモバイルWebのバグを防ぎたいとき
@media (pointer: coarse)hover: none:active

<main class="stage">
<button class="btn is-demo" type="button">次へ</button>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream; // 버튼 색
@include stage($stage-orange);
.btn {
position: relative;
border: 0;
border-radius: $r-control;
padding: 18px 44px;
font-weight: 600; font-size: 18px; line-height: 1; font-family: inherit;
color: $stage-ink;
background: $color;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background $duration $easing, color $duration $easing;
// 규칙8 한 겹: 색 반전 순간 버튼 바깥으로 두 겹 링이 서로 다른 속도로 불규칙하게 번지다 감쇠
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba($subject-blue, .48), 0 0 0 0 rgba($subject-blue, .28);
opacity: 0;
pointer-events: none;
}
}
// 실제 규칙: 호버가 없는(터치) 기기에서만 :active 배경을 준다.
// 마우스 기기는 이 블록을 아예 안 읽어서, 탭 뒤 색이 눌어붙는 일이 없다.
@media (hover: none) and (pointer: coarse) {
.btn:active {
background: $stage-ink;
color: $color;
}
.btn:active::after {
animation: ripple-wave $dur-enter $easing;
}
}
// 데모용: 렌더 환경은 마우스 포인터로 잡히므로 위 미디어쿼리가 실제로 켜졌을 때의
// 모습을 .is-demo 로 강제 재생한다 (실제 터치 여부와 무관).
.btn.is-demo {
animation: touch-active-loop $dur-loop $easing infinite;
}
.btn.is-demo::after {
animation: ripple-loop $dur-loop $easing infinite;
}
@keyframes touch-active-loop {
0%, 20% { background: $subject-cream; color: $stage-ink; }
50%, 75% { background: $stage-ink; color: $subject-cream; }
100% { background: $subject-cream; color: $stage-ink; }
}
@keyframes ripple-wave {
0% { box-shadow: 0 0 0 0 rgba($subject-blue, .48), 0 0 0 0 rgba($subject-blue, .28); opacity: 1; }
38% { box-shadow: 0 0 0 10px rgba($subject-blue, .2), 0 0 0 4px rgba($subject-blue, .26); opacity: .74; }
66% { box-shadow: 0 0 0 17px rgba($subject-blue, .09), 0 0 0 11px rgba($subject-blue, .15); opacity: .36; }
100% { box-shadow: 0 0 0 24px rgba($subject-blue, 0), 0 0 0 20px rgba($subject-blue, 0); opacity: 0; }
}
@keyframes ripple-loop {
0%, 20% { box-shadow: 0 0 0 0 rgba($subject-blue, .48), 0 0 0 0 rgba($subject-blue, .28); opacity: 1; }
38% { box-shadow: 0 0 0 10px rgba($subject-blue, .2), 0 0 0 4px rgba($subject-blue, .26); opacity: .74; }
55% { box-shadow: 0 0 0 17px rgba($subject-blue, .09), 0 0 0 11px rgba($subject-blue, .15); opacity: .36; }
75%, 100% { box-shadow: 0 0 0 24px rgba($subject-blue, 0), 0 0 0 20px rgba($subject-blue, 0); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn::after { animation: none !important; opacity: 0; }
.btn.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.btn {
position: relative;
border: 0;
border-radius: 12px;
padding: 18px 44px;
font-weight: 600;
font-size: 18px;
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 220ms ease-out, color 220ms ease-out;
}
.btn::after {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.48), 0 0 0 0 rgba(47, 109, 246, 0.28);
opacity: 0;
pointer-events: none;
}
@media (hover: none) and (pointer: coarse) {
.btn:active {
background: #17141a;
color: #fff7e6;
}
.btn:active::after {
animation: ripple-wave 600ms ease-out;
}
}
.btn.is-demo {
animation: touch-active-loop 2s ease-out infinite;
}
.btn.is-demo::after {
animation: ripple-loop 2s ease-out infinite;
}
@keyframes touch-active-loop {
0%, 20% {
background: #fff7e6;
color: #17141a;
}
50%, 75% {
background: #17141a;
color: #fff7e6;
}
100% {
background: #fff7e6;
color: #17141a;
}
}
@keyframes ripple-wave {
0% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.48), 0 0 0 0 rgba(47, 109, 246, 0.28);
opacity: 1;
}
38% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0.2), 0 0 0 4px rgba(47, 109, 246, 0.26);
opacity: 0.74;
}
66% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0.09), 0 0 0 11px rgba(47, 109, 246, 0.15);
opacity: 0.36;
}
100% {
box-shadow: 0 0 0 24px rgba(47, 109, 246, 0), 0 0 0 20px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@keyframes ripple-loop {
0%, 20% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.48), 0 0 0 0 rgba(47, 109, 246, 0.28);
opacity: 1;
}
38% {
box-shadow: 0 0 0 10px rgba(47, 109, 246, 0.2), 0 0 0 4px rgba(47, 109, 246, 0.26);
opacity: 0.74;
}
55% {
box-shadow: 0 0 0 17px rgba(47, 109, 246, 0.09), 0 0 0 11px rgba(47, 109, 246, 0.15);
opacity: 0.36;
}
75%, 100% {
box-shadow: 0 0 0 24px rgba(47, 109, 246, 0), 0 0 0 20px rgba(47, 109, 246, 0);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
.btn::after {
animation: none !important;
opacity: 0;
}
.btn.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./TouchActiveButton.module.scss";
export type TouchActiveButtonProps = {
label: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** @media (hover: none) and (pointer: coarse) 에서만 :active 배경이 걸린다. 마우스 기기에서는 이 규칙이 아예 읽히지 않는다. */
export default function TouchActiveButton({
label,
duration = 220,
easing = "ease-out",
color = "#fff7e6",
}: TouchActiveButtonProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<button type="button" className={styles.btn} style={vars}>
{label}
</button>
);
}03 hoverなし端末専用の:active — 計測 累積面積 8.344% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · CSS タップ エフェクト 9選 — 一行でコピペOK
このデモが属する記事
CSS タップ エフェクト 9選 — 一行でコピペOKcss タップ エフェクトとは、指でボタンやカード、リンクに触れた瞬間だけ色・大きさ・影が変わり、入力が確かに届いたとスマホの画面だけで伝える演出です。この記事では9種類を選び、それぞれ6〜25行のコードとして実装しました。4つは@medi この記事のデモをすべて見る


