05押し込みスクイッシュ
押している間ノブが scaleX で横に伸び、押し込まれる触感を模し、離すと元の丸い形にバネで戻る。
使いどころタッチ中心のモバイルフォーム、触感を強調した設定UI
scaleX:active0.15초

<main class="stage">
<label class="switch is-demo">
<input type="checkbox" class="switch__input" role="switch">
<span class="switch__track">
<span class="switch__bg switch__bg--off"></span>
<span class="switch__bg switch__bg--on"></span>
<span class="switch__knob"></span>
</span>
<span class="switch__label">振動フィードバック</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-instant;
$easing: $ease-pop;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.switch {
position: relative;
display: inline-flex; align-items: center; gap: clamp(10px, 3vw, 14px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.switch__input {
position: absolute; inset: 0; width: 100%; height: 100%;
margin: 0; opacity: 0; cursor: pointer;
}
.switch__track {
position: relative; overflow: hidden;
width: clamp(44px, 12vw, 52px); height: clamp(24px, 6.5vw, 28px);
border-radius: $r-pill;
transition: box-shadow $duration $easing;
}
.switch__bg { position: absolute; inset: 0; }
.switch__bg--off { background: rgba(255, 255, 255, .18); opacity: 1; }
.switch__bg--on { background: $color; opacity: 0; }
.switch__knob {
--x: 0px; --sx: 1; --sy: 1;
position: absolute; top: 3px; left: 3px; z-index: 1;
width: clamp(18px, 5vw, 22px); height: clamp(18px, 5vw, 22px);
border-radius: 50%;
background: $subject-cream;
box-shadow: $shadow-press;
transform: translateX(var(--x)) scaleX(var(--sx)) scaleY(var(--sy));
transition: transform $duration $easing;
}
.switch__label { color: $subject-cream; font-weight: 600; font-size: clamp(14px, 4vw, 17px); line-height: 1; font-family: inherit; }
// ── 실제 동작: :checked 는 위치, :active 는 눌림만 (서로 안 덮는다) ──
.switch__input:checked ~ .switch__track .switch__bg--off { opacity: 0; transition: opacity $duration $easing; }
.switch__input:checked ~ .switch__track .switch__bg--on { opacity: 1; transition: opacity $duration $easing; }
.switch__input:checked ~ .switch__track .switch__knob { --x: 24px; }
.switch:active .switch__knob { --sx: 1.35; --sy: .8; }
.switch__input:focus-visible ~ .switch__track {
outline: 2px solid $subject-cream; outline-offset: 3px;
}
// ── 데모 루프: 눌림 → 이동 → 눌림 → 되돌아감 ────────────
// 한 겹: 이동 도착 지점(28%) 앞에 오버슈트+squash 스트로크를 보강하고,
// 트랙은 상태 전환 때 내부 그림자가 깊어졌다 얕아진다.
.switch.is-demo .switch__bg--off { animation: bg-off-loop $dur-loop $ease-out infinite; }
.switch.is-demo .switch__bg--on { animation: bg-on-loop $dur-loop $ease-out infinite; }
.switch.is-demo .switch__knob { animation: squish-loop $dur-loop $easing infinite; }
.switch.is-demo .switch__track { animation: shadow-depth-loop $dur-loop $easing infinite; }
@keyframes bg-off-loop { 0%, 20% { opacity: 1; } 28%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes bg-on-loop { 0%, 20% { opacity: 0; } 28%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
@keyframes squish-loop {
0%, 8% { transform: translateX(0) scaleX(1) scaleY(1); }
16% { transform: translateX(0) scaleX(1.35) scaleY(.8); }
24% { transform: translateX(26px) scaleX(1.2) scaleY(.88); } // 이동 오버슈트 + squash
28%, 60% { transform: translateX(24px) scaleX(1) scaleY(1); }
68% { transform: translateX(24px) scaleX(1.35) scaleY(.8); }
85%, 100% { transform: translateX(0) scaleX(1) scaleY(1); }
}
@keyframes shadow-depth-loop {
0%, 20% { box-shadow: inset 0 1px 3px rgba(0, 0, 0, .15); }
24% { box-shadow: inset 0 3px 8px rgba(0, 0, 0, .28); }
28%, 60% { box-shadow: inset 0 3px 8px rgba(0, 0, 0, .28); }
85%, 100% { box-shadow: inset 0 1px 3px rgba(0, 0, 0, .15); }
}
@media (prefers-reduced-motion: reduce) {
.switch.is-demo .switch__bg--off, .switch.is-demo .switch__bg--on, .switch.is-demo .switch__knob,
.switch.is-demo .switch__track {
animation: none !important;
}
.switch__knob, .switch__bg--off, .switch__bg--on { transition: none; }
.switch__track { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.switch {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(10px, 3vw, 14px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.switch__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.switch__track {
position: relative;
overflow: hidden;
width: clamp(44px, 12vw, 52px);
height: clamp(24px, 6.5vw, 28px);
border-radius: 999px;
transition: box-shadow 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.switch__bg {
position: absolute;
inset: 0;
}
.switch__bg--off {
background: rgba(255, 255, 255, 0.18);
opacity: 1;
}
.switch__bg--on {
background: #2f6df6;
opacity: 0;
}
.switch__knob {
--x: 0px;
--sx: 1;
--sy: 1;
position: absolute;
top: 3px;
left: 3px;
z-index: 1;
width: clamp(18px, 5vw, 22px);
height: clamp(18px, 5vw, 22px);
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(var(--x)) scaleX(var(--sx)) scaleY(var(--sy));
transition: transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.switch__label {
color: #fff7e6;
font-weight: 600;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
}
.switch__input:checked ~ .switch__track .switch__bg--off {
opacity: 0;
transition: opacity 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.switch__input:checked ~ .switch__track .switch__bg--on {
opacity: 1;
transition: opacity 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.switch__input:checked ~ .switch__track .switch__knob {
--x: 24px;
}
.switch:active .switch__knob {
--sx: 1.35;
--sy: .8;
}
.switch__input:focus-visible ~ .switch__track {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.switch.is-demo .switch__bg--off {
animation: bg-off-loop 2s ease-out infinite;
}
.switch.is-demo .switch__bg--on {
animation: bg-on-loop 2s ease-out infinite;
}
.switch.is-demo .switch__knob {
animation: squish-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.switch.is-demo .switch__track {
animation: shadow-depth-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes bg-off-loop {
0%, 20% {
opacity: 1;
}
28%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes bg-on-loop {
0%, 20% {
opacity: 0;
}
28%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes squish-loop {
0%, 8% {
transform: translateX(0) scaleX(1) scaleY(1);
}
16% {
transform: translateX(0) scaleX(1.35) scaleY(0.8);
}
24% {
transform: translateX(26px) scaleX(1.2) scaleY(0.88);
}
28%, 60% {
transform: translateX(24px) scaleX(1) scaleY(1);
}
68% {
transform: translateX(24px) scaleX(1.35) scaleY(0.8);
}
85%, 100% {
transform: translateX(0) scaleX(1) scaleY(1);
}
}
@keyframes shadow-depth-loop {
0%, 20% {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.15);
}
24% {
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.28);
}
28%, 60% {
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.28);
}
85%, 100% {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.15);
}
}
@media (prefers-reduced-motion: reduce) {
.switch.is-demo .switch__bg--off, .switch.is-demo .switch__bg--on, .switch.is-demo .switch__knob,
.switch.is-demo .switch__track {
animation: none !important;
}
.switch__knob, .switch__bg--off, .switch__bg--on {
transition: none;
}
.switch__track {
transition: none;
}
}document.querySelectorAll(".switch").forEach(function (s) {
var input = s.querySelector(".switch__input");
var sync = function () { input.setAttribute("aria-checked", String(input.checked)); };
input.addEventListener("pointerdown", function () { s.classList.remove("is-demo"); });
input.addEventListener("change", sync);
sync();
});import { useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./SquishToggle.module.scss";
export type SquishToggleProps = {
label?: string;
/** 눌림 트랜지션 시간 (기본 140ms) */
duration?: number;
/** 팝 이징 */
easing?: string;
color?: string;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SquishToggle({
label = "진동 피드백",
duration = 140,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
defaultChecked = false,
onChange,
}: SquishToggleProps) {
const [checked, setChecked] = useState(defaultChecked);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<label className={styles.switch} style={vars}>
<input
type="checkbox"
role="switch"
aria-checked={checked}
className={styles.switch__input}
checked={checked}
onChange={(e: ChangeEvent<HTMLInputElement>) => { setChecked(e.target.checked); onChange?.(e.target.checked); }}
/>
<span className={styles.switch__track}>
<span className={styles["switch__bg--off"]} />
<span className={styles["switch__bg--on"]} />
<span className={styles.switch__knob} />
</span>
<span className={styles.switch__label}>{label}</span>
</label>
);
}05 押し込みスクイッシュ — 計測 累積面積 0.917% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · CSS トグルスイッチ 9選 — 一行でコピペOK
このデモが属する記事
CSS トグルスイッチ 9選 — 一行でコピペOKcss トグルスイッチは、チェックボックス一つで実際にオン・オフができ、トラックとノブの位置・色・アイコンだけを変えて見た目を作るコントロールです。ここでは9種類を集め、どれもtransformとopacityを軸にしたコードで組みました。 この記事のデモをすべて見る


