PlSlider
범위 위에서 값을 고릅니다. 레일은 중립 색의 홈이고, 그 홈을 채우는 구간은 버튼을 이루는 것과 같은 그러데이션입니다.
레일은 --plass-track, PlSwitch의 꺼짐 상태와 같은 잉크입니다. 채워진 field가 그렇듯 유리에 inset 그림자를 넣은 것이 아닙니다. field는 들여다보는 상자이고 레일은 따라 보는 선이며, 레일에서 정작 중요한 것은 아무것도 올라가 있지 않은 구간인데, 흰 바탕에 흰 홈에는 바로 그 구간이 없습니다.
import { PlSlider } from 'plass-ui';
<PlSlider label="Volume" value={volume} onValueChange={setVolume} showValue />;import 'package:plass_ui/plass_ui.dart';
PlSlider(
label: const Text('Volume'),
values: <double>[volume],
showValue: true,
onChanged: (List<double> next) => setState(() => volume = next.first),
);Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 홈 두께, thumb 지름, 그리고 라벨의 타입 스케일 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 채워진 구간의 그러데이션과 thumb의 색 |
| elevation공통 | 0 | 1 | 2 | 3 | 1 | thumb의 그림자 깊이. 누르는 부분이므로 컨트롤과 같은 기본값 1 |
| orientation공통 | 'horizontal' | 'vertical' | 'horizontal' | 슬라이더가 놓이는 방향. 세로 슬라이더는 자기 길이가 없으므로 높이를 주세요 |
| value | number | number[] | — | 현재 값. 배열을 주면 그 개수만큼 thumb이 있는 range 슬라이더가 됩니다 |
| defaultValue | number | number[] | — | uncontrolled일 때의 시작 값 |
| onValueChange | (value: number | number[]) => void | — | 값이 바뀔 때 호출됩니다 |
| min · max · step | number | 0 · 100 · 1 | 범위와 눈금. Base UI가 그대로 받습니다 |
| label | ReactNode | — | 트랙 위 라벨 |
| description | ReactNode | — | 트랙 아래 보조 설명 |
| showValue | boolean | ((formatted, values) => ReactNode) | false | 라벨 옆에 현재 값을 보여 줍니다. 함수를 주면 서식을 직접 정합니다 |
| disabled | boolean | false | 사용 불가. 채도가 빠지고 페이지가 비쳐 보이며, 포커스 순서에서 빠집니다 |
| name | string | — | form 제출 시 이 컨트롤을 식별하는 이름 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| values * | List<double> | — | 고른 값, 또는 고른 구간의 양 끝. 값이 하나여도 목록입니다 — range로 만드는 것은 길이입니다 |
| onChanged | ValueChanged<List<double>>? | — | 값이 바뀔 때 호출됩니다 |
| onChangeEnd | ValueChanged<List<double>>? | — | thumb을 놓았을 때 한 번 |
| min · max · step | double | 0 · 100 · 1 | 범위와 눈금. Base UI가 그대로 받습니다 |
| size공통 | PlassSize | PlassSize.md | 홈 두께, thumb 지름, 그리고 라벨의 타입 스케일 |
| color공통 | PlassColor | PlassColor.primary | 채워진 구간의 그러데이션과 thumb의 색 |
| elevation공통 | int | 1 | thumb의 그림자 깊이. 누르는 부분이므로 컨트롤과 같은 기본값 1 |
| orientation공통 | PlassOrientation | PlassOrientation.horizontal | 슬라이더가 놓이는 방향. 세로 슬라이더는 자기 길이가 없으므로 높이를 주세요 |
| length | double? | — | 구간의 길이. 세로 슬라이더는 자기 길이가 없으므로 여기서 받습니다 — 기본은 160 |
| label | Widget? | — | 트랙 위 라벨 |
| description | Widget? | — | 트랙 아래 보조 설명 |
| showValue | bool | false | 라벨 옆에 현재 값을 보여 줍니다. 함수를 주면 서식을 직접 정합니다 |
| formatValue | String Function(List<double>)? | — | 그 값을 어떻게 쓸지. 빼면 소수점 없이 찍고 en dash로 잇습니다 |
| disabled | bool | false | 사용 불가. 채도가 빠지고 페이지가 비쳐 보이며, 포커스 순서에서 빠집니다 |
| semanticLabel | String? | — | 보이는 label이 없는 슬라이더를 스크린 리더가 부를 이름 |
Base UI Slider.Root의 나머지 prop은 그대로 전달됩니다: minStepsBetweenValues, largeStep, format, onValueCommitted, name, disabled.
values는 값이 하나일 때도 언제나 목록입니다. 어느 쪽이든 같은 파라미터이고, range로 만드는 것은 길이입니다.
여기에는 variant가 없습니다. 세 재질은 "이 표면이 무엇으로 되어 있는가"에 대한 답인데, 슬라이더는 한 번에 두 표면입니다. 홈, 그리고 그 위를 지나가는 키. 어느 쪽도 고를 여지가 없습니다.
화살표 키나 rail 누름, 바깥에서 바꾼 값처럼 끌지 않고 바뀐 값에는 thumb이 이동합니다. 나머지 전부와 같은 duration이고, 뒤의 run도 같은 속도로 찹니다. 손가락 아래에서는 이동하지 않습니다. 포인터를 향해 서서히 따라가는 thumb은 포인터보다 뒤처져서 느린 컨트롤로 읽히기 때문입니다. 라이브러리에서 위치에 애니메이션을 주는 곳은 여기 하나뿐이고, transform 금지 규칙도 지킵니다. 움직이는 것은 컨트롤이 아니라 값입니다.
라이브러리 전체에서 공유 축(size color elevation orientation)이 뜻하는 바는 prop 규칙에 있습니다.
Examples
Range
값을 둘 이상 주면 항목 수만큼 thumb이 생기며 range 슬라이더가 됩니다. 별도의 range prop이 없는 이유는, 값의 모양이 이미 어느 쪽인지 말하고 있기 때문입니다.
thumb끼리 교차하지 않습니다. 값은 양옆 이웃 사이에 붙들리므로, 양 끝이 뒤바뀐 range는 거꾸로 입력된 range이고 그 처리는 모든 호출자가 아니라 여기에 있습니다.
import { useState } from 'react';
import { PlSlider } from 'plass-ui';
export default function SliderRange() {
const [price, setPrice] = useState<number[]>([25, 75]);
return (
<PlSlider
className="max-w-sm"
label="Price"
value={price}
min={0}
max={100}
onValueChange={(next) => setPrice(next as number[])}
showValue={(formatted) => `$${formatted[0]} – $${formatted[1]}`}
/>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderRange extends StatefulWidget {
const SliderRange({super.key});
@override
State<SliderRange> createState() => _SliderRangeState();
}
class _SliderRangeState extends State<SliderRange> {
List<double> _price = <double>[25, 75];
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: PlSlider(
label: const Text('Price'),
values: _price,
showValue: true,
formatValue: (List<double> values) =>
'\$${values.first.round()} – \$${values.last.round()}',
onChanged: (List<double> next) => setState(() => _price = next),
),
);
}
}color
채워진 구간은 색 계열의 그러데이션(solid 버튼이 입는 것과 같은 135° 두 stop 스윕)이고, thumb은 그 위에 놓입니다. 페이지 자체의 surface 색으로 테두리를 둘러서 뒤의 구간에 녹아 사라지지 않습니다.
import { PlSlider } from 'plass-ui';
export default function SliderColors() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
{(['primary', 'success', 'warning', 'danger'] as const).map((color) => (
<PlSlider key={color} color={color} label={color} defaultValue={60} showValue />
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderColors extends StatelessWidget {
const SliderColors({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: <Widget>[
for (final color in <PlassColor>[
PlassColor.primary,
PlassColor.success,
PlassColor.warning,
PlassColor.danger,
])
PlSlider(
color: color,
label: Text(color.name),
values: const <double>[60],
showValue: true,
onChanged: (List<double> next) {},
),
],
),
);
}
}min · max · step
step이 thumb이 멈출 수 있는 자리를 정합니다. 멈출 자리가 다섯 개인 슬라이더도 여전히 슬라이더이지 segmented control이 아닙니다. 드래그로 고르고, 값들이 하나의 척도 위에 있기 때문입니다.
import { PlSlider } from 'plass-ui';
export default function SliderSteps() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<PlSlider label="Continuous" defaultValue={40} showValue />
<PlSlider label="In tens" defaultValue={40} step={10} showValue />
<PlSlider
label="1 to 5"
defaultValue={3}
min={1}
max={5}
step={1}
showValue
description="Every step is a whole number, so the thumb snaps."
/>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderSteps extends StatelessWidget {
const SliderSteps({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: <Widget>[
PlSlider(
label: const Text('Continuous'),
values: const <double>[40],
showValue: true,
onChanged: (List<double> next) {},
),
PlSlider(
label: const Text('In tens'),
values: const <double>[40],
step: 10,
showValue: true,
onChanged: (List<double> next) {},
),
PlSlider(
label: const Text('1 to 5'),
values: const <double>[3],
min: 1,
max: 5,
showValue: true,
description: const Text('Every step is a whole number, so the thumb snaps.'),
onChanged: (List<double> next) {},
),
],
),
);
}
}showValue
true는 값을 그대로 찍습니다. 함수를 주면 Base UI가 이미 지역화해 둔 문자열과 원래 숫자를 둘 다 받으므로, 통화나 퍼센트, 시간 표기가 한 줄로 끝납니다.
showValue가 숫자를 켜고 formatValue가 무엇을 말할지 정합니다. 통화, 퍼센트, 시간. 빼면 소수점 없이 찍고 en dash로 잇습니다.
값은 thumb을 따라다니지 않고 라벨 줄의 끝에 놓입니다. 움직이는 숫자는 읽기 어렵고, 위아래로 쌓인 두 슬라이더 사이에서는 비교할 수도 없습니다.
size
홈과 thumb, 라벨이 함께 움직입니다. 모든 단계에서 thumb은 홈보다 한참 큽니다. 실제로 잡을 수 있는 부분은 thumb뿐이고, 6px 레일에 맞춘 thumb은 터치스크린에서 아무도 잡지 못합니다.
import { PlSlider } from 'plass-ui';
export default function SliderSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
{(['xs', 'sm', 'md', 'lg', 'xl'] as const).map((size) => (
<PlSlider key={size} size={size} label={size} defaultValue={55} showValue />
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderSizes extends StatelessWidget {
const SliderSizes({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: <Widget>[
for (final size in PlassSize.values)
PlSlider(
size: size,
label: Text(size.name),
values: const <double>[55],
showValue: true,
onChanged: (List<double> next) {},
),
],
),
);
}
}orientation
세로 슬라이더는 자기 길이가 없어서 하나를 받습니다. 기본값은 160px이고, 믹서의 페이더처럼 더 길어야 하면 class로 덮어쓰세요`length`로 덮어쓰세요.
import { PlSlider } from 'plass-ui';
export default function SliderOrientation() {
return (
<div className="flex items-end gap-8">
<PlSlider orientation="vertical" defaultValue={30} aria-label="Bass" />
<PlSlider orientation="vertical" defaultValue={65} aria-label="Mid" />
<PlSlider orientation="vertical" defaultValue={48} aria-label="Treble" />
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderOrientation extends StatefulWidget {
const SliderOrientation({super.key});
@override
State<SliderOrientation> createState() => _SliderOrientationState();
}
class _SliderOrientationState extends State<SliderOrientation> {
final Map<String, double> _bands = <String, double>{'Bass': 30, 'Mid': 65, 'Treble': 48};
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 32,
children: <Widget>[
for (final band in _bands.keys)
PlSlider(
orientation: PlassOrientation.vertical,
semanticLabel: band,
values: <double>[_bands[band]!],
onChanged: (List<double> next) => setState(() => _bands[band] = next.first),
),
],
);
}
}disabled
다른 곳과 마찬가지로 빛이 꺼지는 것입니다. 모양과 자리는 그대로 두고 채도와 불투명도 절반이 빠집니다.
import { PlSlider } from 'plass-ui';
export default function SliderStates() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<PlSlider label="Default" defaultValue={45} showValue />
<PlSlider label="Disabled" defaultValue={45} showValue disabled />
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderStates extends StatelessWidget {
const SliderStates({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: <Widget>[
PlSlider(
label: const Text('Default'),
values: const <double>[45],
showValue: true,
onChanged: (List<double> next) {},
),
const PlSlider(
label: Text('Disabled'),
values: <double>[45],
showValue: true,
disabled: true,
),
],
),
);
}
}Accessibility
- 각 thumb은 진짜
<input type="range">입니다. 브라우저 자체의 slider 의미론, 포커스 순서,disabled가 전부 그대로 따라옵니다. label은 Base UI가 컨트롤에 엮어 줍니다. 라벨이 없는 경우(여러 개가 늘어선 페이더 같은) 에는aria-label을 주세요.- 키보드는 primitive의 것입니다. ← → ↑ ↓로 한 칸씩, PageUp / PageDown으로 크게, Home과 End로 양 끝까지 갑니다.
- 포인터가 닿는 곳은 레일이 아니라 띠 전체입니다. 컨트롤 박스가 홈 두께의 몇 배라서, 띠 어디를 눌러도 thumb이 그리로 옵니다.
- thumb은 hover와 드래그 중에 자기가 커지는 대신 후광을 두릅니다. 손가락 아래의 것은 절대 크기가 변하지 않습니다.
showValue는 그려진 숫자일 뿐, 접근성 값의 대체물이 아닙니다. 그것은 input의aria-valuenow이고 Base UI가 맞춰 줍니다.
- 슬라이더로 알려지고, 현재 값이 그 값으로 함께 알려집니다. 보이는
label이 없다면(여러 개가 늘어선 페이더처럼)semanticLabel을 주세요. - ← → ↑ ↓가
step하나만큼, PageUp / PageDown이 범위의 10분의 1만큼 옮기고, Home과 End가 양 끝으로 갑니다. - thumb마다 자기 focus stop이 있습니다. range 슬라이더를 조작할 수 있게 하는 것이 이것입니다. Tab으로 양 끝 사이를 옮깁니다.
- 포인터가 닿는 곳은 레일이 아니라 띠 전체입니다. 컨트롤 박스가 홈 두께의 몇 배라서, 띠 어디를 눌러도 가장 가까운 thumb이 그리로 옵니다.
- thumb은 hover와 드래그 중에 자기가 커지는 대신 후광을 두릅니다. 손가락 아래의 것은 절대 크기가 변하지 않습니다.
showValue는 그려진 숫자일 뿐, 알려지는 값의 대체물이 아닙니다.
React 빌드와 다른 점
| React | Flutter | 이유 |
|---|---|---|
숫자이거나 배열인 value | 언제나 목록인 values | 어느 쪽이든 파라미터는 하나이고, range로 만드는 것은 길이입니다. |
onValueChange / onValueCommitted | onChanged / onChangeEnd | "움직이는 동안"과 "놓았을 때"에 대한 Flutter의 이름입니다. |
boolean이거나 함수인 showValue | showValue와 formatValue | Dart에는 union 타입이 없으니, 숫자를 켜는 것과 무엇을 말할지 정하는 것이 두 파라미터가 됩니다. |
<input type="range"> | 직접 그린 띠와 자체 키 처리 | 키보드를 물려받을 네이티브 range input이 없으므로 키를 여기서 묶습니다. Page와 Home/End를 포함해 같은 조합입니다. |
aria-label | semanticLabel | Flutter의 이름입니다. |
세로 슬라이더 높이를 위한 className | length | 클래스 목록이 없습니다. 길이는 파라미터입니다. |