01Segmented control kiểu pill iOS
Ba radio chia đều chiều rộng, nền pill màu xanh trượt bằng translateX đến đúng nhãn đang được chọn.
Dùng choChuyển chế độ xem trong cài đặt (danh sách/thẻ), bộ lọc trạng thái đơn hàng (tất cả/đang xử lý/hoàn tất)
translateX(N*100%)균등폭 flex0.22초

<main class="stage">
<fieldset class="seg is-demo">
<legend class="sr-only">Bộ lọc trạng thái</legend>
<input type="radio" name="seg1" id="seg1-1" class="seg__radio" checked>
<input type="radio" name="seg1" id="seg1-2" class="seg__radio">
<input type="radio" name="seg1" id="seg1-3" class="seg__radio">
<div class="seg__track">
<span class="seg__pill" aria-hidden="true"></span>
<label class="seg__tab" for="seg1-1">Tất cả</label>
<label class="seg__tab" for="seg1-2">Đang xử lý</label>
<label class="seg__tab" for="seg1-3">Hoàn tất</label>
</div>
</fieldset>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
$ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
@include stage($stage-ink);
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.seg { border: 0; margin: 0; padding: 0; width: min(280px, 84vw); }
.seg__radio { position: absolute; opacity: 0; pointer-events: none; width: 1px; height: 1px; }
.seg__track {
position: relative;
display: flex;
gap: 0;
padding: $sp-1;
background: rgba($subject-cream, .14);
border-radius: $r-pill;
}
.seg__tab {
position: relative; z-index: 1;
flex: 1 1 0;
text-align: center;
padding: $sp-2 $sp-1;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: rgba($subject-cream, .55);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: color $duration $easing;
}
.seg__pill {
position: absolute; top: $sp-1; left: $sp-1; bottom: $sp-1;
width: calc((100% - #{$sp-1} * 2) / 3);
border-radius: $r-pill;
background: $color;
transform: translateX(0);
transform-origin: center;
transition: transform $duration $ease-bounce;
}
// ── 실제 동작: :checked 만으로 필이 균등폭 구간을 옮겨가고, 선택된 라벨만 밝아진다 ──────────────
#seg1-2:checked ~ .seg__track .seg__pill { transform: translateX(100%); }
#seg1-3:checked ~ .seg__track .seg__pill { transform: translateX(200%); }
#seg1-1:checked ~ .seg__track .seg__tab:nth-child(2) { color: $subject-cream; }
#seg1-2:checked ~ .seg__track .seg__tab:nth-child(3) { color: $subject-cream; }
#seg1-3:checked ~ .seg__track .seg__tab:nth-child(4) { color: $subject-cream; }
// ── 데모 루프: 클릭 전까지 세 칸을 스스로 오간다 ──────────────
.seg.is-demo .seg__pill { animation: seg-loop-jelly $dur-loop $easing infinite; }
@keyframes seg-loop {
0%, 15% { transform: translateX(0); }
33%, 48% { transform: translateX(100%); }
66%, 81% { transform: translateX(200%); }
100% { transform: translateX(0); }
}
// 젤리 스트레치 + 스프링 오버슛 보강: 정지 구간 값은 seg-loop 과 동일, 이동 구간에 scaleX·도착 오버슛 삽입
@keyframes seg-loop-jelly {
0%, 15% { transform: translateX(0%) scaleX(1); }
22% { transform: translateX(58%) scaleX(1.25); }
30% { transform: translateX(103%) scaleX(1.05); }
33%, 48% { transform: translateX(100%) scaleX(1); }
55% { transform: translateX(158%) scaleX(1.25); }
63% { transform: translateX(203%) scaleX(1.05); }
66%, 81% { transform: translateX(200%) scaleX(1); }
88% { transform: translateX(92%) scaleX(1.25); }
96% { transform: translateX(-3%) scaleX(1.05); }
100% { transform: translateX(0%) scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.seg__pill { transition: none; }
.seg.is-demo .seg__pill { animation: none !important; }
}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);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.seg {
border: 0;
margin: 0;
padding: 0;
width: min(280px, 84vw);
}
.seg__radio {
position: absolute;
opacity: 0;
pointer-events: none;
width: 1px;
height: 1px;
}
.seg__track {
position: relative;
display: flex;
gap: 0;
padding: 4px;
background: rgba(255, 247, 230, 0.14);
border-radius: 999px;
}
.seg__tab {
position: relative;
z-index: 1;
flex: 1 1 0;
text-align: center;
padding: 8px 4px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.55);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.seg__pill {
position: absolute;
top: 4px;
left: 4px;
bottom: 4px;
width: calc((100% - 4px * 2) / 3);
border-radius: 999px;
background: #2f6df6;
transform: translateX(0);
transform-origin: center;
transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
#seg1-2:checked ~ .seg__track .seg__pill {
transform: translateX(100%);
}
#seg1-3:checked ~ .seg__track .seg__pill {
transform: translateX(200%);
}
#seg1-1:checked ~ .seg__track .seg__tab:nth-child(2) {
color: #fff7e6;
}
#seg1-2:checked ~ .seg__track .seg__tab:nth-child(3) {
color: #fff7e6;
}
#seg1-3:checked ~ .seg__track .seg__tab:nth-child(4) {
color: #fff7e6;
}
.seg.is-demo .seg__pill {
animation: seg-loop-jelly 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes seg-loop {
0%, 15% {
transform: translateX(0);
}
33%, 48% {
transform: translateX(100%);
}
66%, 81% {
transform: translateX(200%);
}
100% {
transform: translateX(0);
}
}
@keyframes seg-loop-jelly {
0%, 15% {
transform: translateX(0%) scaleX(1);
}
22% {
transform: translateX(58%) scaleX(1.25);
}
30% {
transform: translateX(103%) scaleX(1.05);
}
33%, 48% {
transform: translateX(100%) scaleX(1);
}
55% {
transform: translateX(158%) scaleX(1.25);
}
63% {
transform: translateX(203%) scaleX(1.05);
}
66%, 81% {
transform: translateX(200%) scaleX(1);
}
88% {
transform: translateX(92%) scaleX(1.25);
}
96% {
transform: translateX(-3%) scaleX(1.05);
}
100% {
transform: translateX(0%) scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.seg__pill {
transition: none;
}
.seg.is-demo .seg__pill {
animation: none !important;
}
}// .is-demo 자동 순환 (실제 클릭이 오면 멈춘다, 7줄)
var seg = document.querySelector('.seg');
var radios = seg.querySelectorAll('.seg__radio'); var i = 0;
var t = setInterval(function () { radios[i % radios.length].checked = true; i++; }, 700);
radios.forEach(function (r) { r.addEventListener('pointerdown', function () { seg.classList.remove('is-demo'); clearInterval(t); }); });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SegmentedControlIosPillToggle.module.scss";
export type SegmentedControlIosPillToggleProps = {
legend?: string;
options?: string[];
duration?: number;
easing?: string;
pillColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--pill"?: string };
export default function SegmentedControlIosPillToggle({
legend = "Status filter",
options = ["All", "Active", "Done"],
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
pillColor = "#2f6df6",
}: SegmentedControlIosPillToggleProps) {
const [index, setIndex] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--pill": pillColor };
return (
<fieldset className={styles.seg} style={vars}>
<legend className={styles.srOnly}>{legend}</legend>
<div className={styles.track}>
<span
className={styles.pill}
style={{ transform: `translateX(${index * 100}%)`, width: `calc(100% / ${options.length})` }}
aria-hidden="true"
/>
{options.map((label, i) => (
<label key={label} className={styles.tab} data-active={index === i}>
<input
type="radio"
name="seg"
className={styles.srInput}
checked={index === i}
onChange={() => setIndex(i)}
/>
{label}
</label>
))}
</div>
</fieldset>
);
}01 Segmented control kiểu pill iOS — Diện tích đo được 6.007% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 Segmented Control UI — dán là chạy
Bài viết chứa demo này
9 Segmented Control UI — dán là chạySegmented control là nút chuyển tab hình viên thuốc mà bạn hay thấy trong màn hình cài đặt iOS, chọn một trong vài lựa c Xem tất cả demo trong bài này
Nếu bạn thích hiệu ứng này - demo tương tự
Giao diện nhập liệu05 Bật tắt chế độ che sốOTP Input UI: 9 tình huống kèm validation
Giao diện đăng nhập · xác thực01 Thẻ đăng nhập kiểu kính mờ (glassmorphism)9 mẫu form đăng ký & đăng nhập copy-paste được
Giao diện danh sách · bảng01 Vuốt để xóa (reveal)9 mẫu List UI: swipe to delete css và phân trang