Knob
Examples
(13)Properties
(52)Playground
Basic
Loading demo…
import
{
useState
}
from
'react'
;
import
{
Knob
}
from
'@platform-blocks/react-ui-library'
;
export
function
Demo
(
)
{
const
[
value
,
setValue
]
=
useState
(
90
)
;
return
(
<Knob
value
=
{
value
}
onChange
=
{
setValue
}
valueLabel
=
{
{
formatter
:
(
current
)
=>
Math
.
round
(
current
)
,
suffix
:
'°'
,
}
}
/>
)
;
}
Events
Loading demo…
import
{
useState
}
from
'react'
;
import
{
Block
,
DataList
,
Knob
}
from
'@platform-blocks/react-ui-library'
;
export
function
Demo
(
)
{
const
[
value
,
setValue
]
=
useState
(
32
)
;
const
[
isScrubbing
,
setIsScrubbing
]
=
useState
(
false
)
;
const
[
committed
,
setCommitted
]
=
useState
(
value
)
;
return
(
<Block
direction
=
"row"
align
=
"center"
justify
=
"space-evenly"
>
<Knob
value
=
{
value
}
onChange
=
{
setValue
}
onChangeEnd
=
{
setCommitted
}
onScrubStart
=
{
(
)
=>
setIsScrubbing
(
true
)
}
onScrubEnd
=
{
(
)
=>
setIsScrubbing
(
false
)
}
/>
<DataList
data
=
{
[
{
label
:
'Current'
,
value
:
Math
.
round
(
value
)
}
,
{
label
:
'Last commit'
,
value
:
Math
.
round
(
committed
)
}
,
{
label
:
'State'
,
value
:
isScrubbing
?
'Scrubbing'
:
'Idle'
}
,
]
}
/>
</Block
>
)
;
}
Visual Variants
variant picks the dial's look, independent of behavior. Each preset sets stroke weights, caps, body fill, and which indicator carries the value, taking its colors from the theme so the same knob reads correctly in light and dark. Presets are merged under appearance, so any single property stays overridable.Loading demo…
import
{
useState
}
from
'react'
;
import
{
Block
,
Knob
,
Row
,
Text
}
from
'@platform-blocks/react-ui-library'
;
import
type
{
KnobVariant
}
from
'@platform-blocks/react-ui-library'
;
const
VARIANTS
:
{
variant
:
KnobVariant
;
blurb
:
string
}
[
]
=
[
{
variant
:
'default'
,
blurb
:
'Stock dial'
}
,
{
variant
:
'minimal'
,
blurb
:
'Hairline, dense UIs'
}
,
{
variant
:
'digital'
,
blurb
:
'Hard edges, lit marker'
}
,
{
variant
:
'retro'
,
blurb
:
'Solid body, indicator arm'
}
,
{
variant
:
'studio'
,
blurb
:
'Plugin rack'
}
,
]
;
export
function
Demo
(
)
{
const
[
value
,
setValue
]
=
useState
(
62
)
;
return
(
<Block
fullWidth
direction
=
"row"
justify
=
"space-between"
>
{
VARIANTS
.
map
(
(
{
variant
,
blurb
}
)
=>
(
<Block
key
=
{
variant
}
align
=
"center"
gap
=
"xs"
>
<Knob
value
=
{
value
}
onChange
=
{
setValue
}
variant
=
{
variant
}
/>
<Text
size
=
"sm"
weight
=
"600"
>
{
variant
}
</Text
>
</Block
>
)
)
}
</Block
>
)
;
}
Endless
Loading demo…
import
{
useMemo
,
useState
}
from
'react'
;
import
{
Block
,
Knob
}
from
'@platform-blocks/react-ui-library'
;
export
function
Demo
(
)
{
const
[
value
,
setValue
]
=
useState
(
0
)
;
const
normalizedAngle
=
useMemo
(
(
)
=>
(
(
value
%
360
)
+
360
)
%
360
,
[
value
]
)
;
const
rotations
=
useMemo
(
(
)
=>
value
/
360
,
[
value
]
)
;
return
(
<Block
fullWidth
>
<Knob
value
=
{
value
}
onChange
=
{
setValue
}
behavior
=
"endless"
valueLabel
=
{
{
formatter
:
(
)
=>
`${Math.round(normalizedAngle)}°`
,
secondary
:
{
formatter
:
(
)
=>
`${rotations.toFixed(2)} turns`
,
}
,
}
}
/>
</Block
>
)
;
}
Dual Readout
dual behavior with a center frequency label and a derived percentage below the knob.Loading demo…
import
{
useMemo
,
useState
}
from
'react'
;
import
{
Block
,
Knob
}
from
'@platform-blocks/react-ui-library'
;
export
function
Demo
(
)
{
const
[
cutoff
,
setCutoff
]
=
useState
(
3200
)
;
const
percent
=
useMemo
(
(
)
=>
Math
.
round
(
(
(
cutoff
-
200
)
/
(
8000
-
200
)
)
*
100
)
,
[
cutoff
]
)
;
return
(
<Block
fullWidth
>
<Knob
value
=
{
cutoff
}
onChange
=
{
setCutoff
}
min
=
{
200
}
max
=
{
8000
}
step
=
{
50
}
behavior
=
"dual"
size
=
{
180
}
valueLabel
=
{
{
position
:
'center'
,
formatter
:
(
val
)
=>
`${Math.round(val)} Hz`
,
secondary
:
{
position
:
'bottom'
,
formatter
:
(
)
=>
`${percent}% span`
,
}
,
}
}
marks
=
{
[
{
value
:
400
,
label
:
'Warm'
}
,
{
value
:
1200
,
label
:
'Neutral'
}
,
{
value
:
6400
,
label
:
'Bright'
}
,
]
}
/>
</Block
>
)
;
}
Status Selector
status behavior with icon-enhanced marks, accent colors, and the active scene named directly beneath the icon in the center slot.Loading demo…
import
{
useMemo
,
useState
}
from
'react'
;
import
{
Block
,
Icon
,
Knob
}
from
'@platform-blocks/react-ui-library'
;
import
{
STATUS_SCENES
}
from
'./data'
;
export
function
Demo
(
)
{
const
[
value
,
setValue
]
=
useState
(
0
)
;
const
statusMarks
=
useMemo
(
(
)
=>
STATUS_SCENES
.
map
(
scene
=>
(
{
.
.
.
scene
,
icon
:
<Icon
name
=
{
scene
.
iconName
}
size
=
"3xl"
color
=
{
scene
.
accentColor
}
/>
,
}
)
)
,
[
]
)
;
const
activeStatus
=
useMemo
(
(
)
=>
statusMarks
.
reduce
(
(
closest
,
mark
)
=>
(
Math
.
abs
(
mark
.
value
-
value
)
<
Math
.
abs
(
closest
.
value
-
value
)
?
mark
:
closest
)
,
statusMarks
[
0
]
)
,
[
statusMarks
,
value
]
)
;
return
(
<Block
>
<Knob
value
=
{
value
}
onChange
=
{
setValue
}
min
=
{
0
}
max
=
{
360
}
step
=
{
90
}
my
=
"3xl"
marks
=
{
statusMarks
}
restrictToMarks
behavior
=
"status"
size
=
{
200
}
valueLabel
=
{
{
position
:
'center'
,
formatter
:
(
)
=>
activeStatus
.
label
,
}
}
/>
</Block
>
)
;
}
Segmented Progress
ring.segmentMode: 'progress', which makes them the progress arc itself: they stop at the current value and nothing is drawn beyond it, so the fill runs through each color in turn.Loading demo…
import
{
useState
}
from
'react'
;
import
{
Block
,
Knob
}
from
'@platform-blocks/react-ui-library'
;
const
ZONES
=
[
{
value
:
60
,
color
:
'#22c55e'
}
,
{
value
:
25
,
color
:
'#f59e0b'
}
,
{
value
:
15
,
color
:
'#ef4444'
}
,
]
;
export
function
Demo
(
)
{
const
[
load
,
setLoad
]
=
useState
(
72
)
;
return
(
<Block
align
=
"center"
>
<Knob
value
=
{
load
}
onChange
=
{
setLoad
}
variant
=
"minimal"
max
=
{
100
}
appearance
=
{
{
arc
:
{
startAngle
:
-
135
,
sweepAngle
:
270
}
,
ring
:
{
segments
:
ZONES
,
segmentMode
:
'progress'
}
,
fill
:
{
radiusOffset
:
-
20
}
,
}
}
valueLabel
=
{
{
formatter
:
(
val
)
=>
`${Math.round(val)}%`
}
}
/>
</Block
>
)
;
}
Tick Selector
activeMode: 'nearest', which lights only the tick the pointer is aimed at. The default 'fill' instead lights every tick up to the value, the way a meter fills.Loading demo…
import
{
useState
}
from
'react'
;
import
{
Block
,
Knob
}
from
'@platform-blocks/react-ui-library'
;
import
{
POSITION_COLORS
}
from
'./data'
;
// Twelve detents on a full circle. `max` is 12 rather than 11 so position 11 sits one step
// short of the top instead of overlapping position 0. Each detent carries its own
const
POSITIONS
=
POSITION_COLORS
.
map
(
(
accentColor
,
index
)
=>
(
{
value
:
index
,
accentColor
}
)
)
;
export
function
Demo
(
)
{
const
[
position
,
setPosition
]
=
useState
(
3
)
;
return
(
<Block
>
<Knob
value
=
{
position
}
onChange
=
{
setPosition
}
min
=
{
0
}
max
=
{
12
}
marks
=
{
POSITIONS
}
restrictToMarks
appearance
=
{
{
accentFromMarks
:
true
,
ticks
:
[
{
source
:
'marks'
,
shape
:
'line'
,
// Only the detent the arm is aimed at lights up. The default 'fill' would
// instead light every tick from 0 up to the current position.
activeMode
:
'nearest'
,
length
:
14
,
width
:
20
,
position
:
'outer'
,
inactiveColor
:
(
{
mark
}
)
=>
(
mark
?
.
accentColor
?
`${mark.accentColor}44`
:
'#475569'
)
,
}
,
]
,
}
}
valueLabel
=
{
{
formatter
:
(
val
)
=>
`${Math.round(val) + 1}`
}
}
/>
</Block
>
)
;
}
Compound Panning
Knob.Root, split progress, and custom tick labels to highlight the compound sub-component API.Loading demo…
import
{
useMemo
,
useState
}
from
'react'
;
import
{
Block
,
Knob
,
Text
}
from
'@platform-blocks/react-ui-library'
;
// The split arc reads the same on both sides of center: direction is carried by which way
// the arc grows and by the L/R label, not by a color change.
const
PAN_COLOR
=
'#4ade80'
;
export
function
Demo
(
)
{
const
[
pan
,
setPan
]
=
useState
(
-
18
)
;
const
readout
=
useMemo
(
(
)
=>
{
if
(
pan
===
0
)
return
'Center'
;
return
pan
>
0
?
`Right ${Math.abs(pan)}`
:
`Left ${Math.abs(pan)}`
;
}
,
[
pan
]
)
;
return
(
<Block
align
=
"center"
>
<Knob.Root
min
=
{
-
100
}
max
=
{
100
}
value
=
{
pan
}
onChange
=
{
setPan
}
step
=
{
1
}
size
=
{
220
}
appearance
=
{
{
arc
:
{
startAngle
:
-
135
,
sweepAngle
:
270
,
clampInput
:
true
}
,
panning
:
{
pivotValue
:
0
,
positiveColor
:
PAN_COLOR
,
negativeColor
:
PAN_COLOR
,
}
,
}
}
>
<Knob.Fill
radiusOffset
=
{
-
28
}
color
=
"#0f172a"
borderWidth
=
{
2
}
borderColor
=
"rgba(148, 163, 184, 0.4)"
/>
<Knob.Ring
color
=
"#0f172a"
trailColor
=
"#1f2937"
/>
<Knob.Progress
mode
=
"split"
thickness
=
{
14
}
roundedCaps
/>
<Knob.Thumb
color
=
"#f8fafc"
strokeWidth
=
{
3
}
strokeColor
=
"#0f172a"
/>
<Knob.ValueLabel
position
=
"center"
formatter
=
{
(
value
)
=>
`${value > 0 ? 'R' : value < 0 ? 'L' : ''}${Math.abs(Math.round(value))}`
}
textStyle
=
{
{
fontSize
:
30
,
fontWeight
:
'700'
,
color
:
'#f8fafc'
}
}
/>
</Knob.Root
>
<Text
size
=
"sm"
color
=
"secondary"
>
Stereo
balance ·
{
readout
}
</Text
>
</Block
>
)
;
}
Pointer Clock
Knob.Root API: a bezel ring, 60 minute marks with bolder hour marks, hour numerals, and Knob.Pointer as the hour hand driven by the value (minutes past 12). The minute and second hands are plain rotated views composed over the same center, synced to the system clock each second.Loading demo…
import
{
useEffect
,
useState
}
from
'react'
;
import
{
View
}
from
'react-native'
;
import
{
Block
,
Knob
,
Text
,
useTheme
}
from
'@platform-blocks/react-ui-library'
;
const
SIZE
=
240
;
const
CENTER
=
SIZE
/
2
;
const
MINUTES_PER_TURN
=
12
*
60
;
const
HOUR_VALUES
=
Array
.
from
(
{
length
:
12
}
,
(
_
,
index
)
=>
index
*
60
)
;
const
HOUR_LABELS
=
[
'12'
,
'1'
,
'2'
,
'3'
,
'4'
,
'5'
,
'6'
,
'7'
,
'8'
,
'9'
,
'10'
,
'11'
]
;
// 60 minute marks around the dial; hour positions are drawn by the layer above.
const
MINUTE_VALUES
=
Array
.
from
(
{
length
:
60
}
,
(
_
,
index
)
=>
index
*
12
)
.
filter
(
(
value
)
=>
value
%
60
!==
0
)
;
const
formatTime
=
(
minutes
:
number
)
=>
{
const
hour
=
Math
.
floor
(
minutes
/
60
)
;
return
`${hour === 0 ? 12 : hour}:${(minutes % 60).toString().padStart(2, '0')}`
;
}
;
/** Hand pivoting on the dial center: the wrapper is twice the hand length, so it rotates around it. */
const
Hand
=
(
{
angle
,
length
,
width
,
color
,
tail
=
0
,
}
:
{
angle
:
number
;
length
:
number
;
width
:
number
;
color
:
string
;
tail
?:
number
;
}
)
=>
(
<View
pointerEvents
=
"none"
style
=
{
{
position
:
'absolute'
,
left
:
CENTER
-
width
/
2
,
top
:
CENTER
-
length
,
width
,
height
:
length
*
2
,
transform
:
[
{
rotate
:
`${angle}deg`
}
]
,
}
}
>
<View
style
=
{
{
width
,
height
:
length
+
tail
,
borderRadius
:
width
/
2
,
backgroundColor
:
color
,
}
}
/>
</View
>
)
;
export
function
Demo
(
)
{
const
theme
=
useTheme
(
)
;
const
face
=
theme
.
backgrounds
.
surface
;
const
ink
=
theme
.
text
.
primary
;
const
accent
=
theme
.
colors
.
primary
[
6
]
;
// Start on a fixed time so server-rendered and client markup match, then sync on mount.
const
[
time
,
setTime
]
=
useState
(
{
minutes
:
10
*
60
+
10
,
seconds
:
0
}
)
;
useEffect
(
(
)
=>
{
const
tick
=
(
)
=>
{
const
now
=
new
Date
(
)
;
setTime
(
{
minutes
:
(
now
.
getHours
(
)
%
12
)
*
60
+
now
.
getMinutes
(
)
,
seconds
:
now
.
getSeconds
(
)
,
}
)
;
}
;
tick
(
)
;
const
id
=
setInterval
(
tick
,
1000
)
;
return
(
)
=>
clearInterval
(
id
)
;
}
,
[
]
)
;
return
(
<Block
align
=
"center"
gap
=
"sm"
>
<View
style
=
{
{
width
:
SIZE
,
height
:
SIZE
}
}
>
<Knob.Root
min
=
{
0
}
max
=
{
MINUTES_PER_TURN
}
value
=
{
time
.
minutes
}
size
=
{
SIZE
}
readOnly
withLabel
=
{
false
}
accessibilityLabel
=
{
`Clock showing ${formatTime(time.minutes)}`
}
appearance
=
{
{
arc
:
{
startAngle
:
0
,
sweepAngle
:
360
,
clampInput
:
true
}
,
}
}
>
<Knob.Ring
thickness
=
{
12
}
color
=
{
ink
}
trailColor
=
{
ink
}
backgroundColor
=
{
face
}
/>
{
/* Chapter ring just inside the minute marks */
}
<Knob.Fill
color
=
{
face
}
radiusOffset
=
{
-
18
}
borderWidth
=
{
1
}
borderColor
=
{
theme
.
backgrounds
.
border
}
/>
<Knob.Progress
visible
=
{
false
}
/>
<Knob.Thumb
visible
=
{
false
}
/>
<Knob.TickLayer
source
=
"values"
values
=
{
MINUTE_VALUES
}
shape
=
"line"
length
=
{
6
}
width
=
{
1.5
}
position
=
"inner"
color
=
{
theme
.
text
.
muted
}
inactiveColor
=
{
theme
.
text
.
muted
}
/>
<Knob.TickLayer
source
=
"values"
values
=
{
HOUR_VALUES
}
shape
=
"line"
length
=
{
12
}
width
=
{
3
}
position
=
"inner"
color
=
{
ink
}
inactiveColor
=
{
ink
}
label
=
{
{
show
:
true
,
formatter
:
(
_
,
index
)
=>
HOUR_LABELS
[
index
]
,
position
:
'inner'
,
offset
:
-
26
,
style
:
{
color
:
ink
,
fontSize
:
15
,
fontWeight
:
'600'
}
,
}
}
/>
{
/* Hour hand — the knob value is minutes past 12, so it advances gradually. */
}
<Knob.Pointer
visible
length
=
{
58
}
width
=
{
6
}
color
=
{
ink
}
counterweight
=
{
{
size
:
12
,
color
:
ink
}
}
/>
</Knob.Root
>
<Hand
angle
=
{
(
(
time
.
minutes
%
60
)
/
60
)
*
360
}
length
=
{
88
}
width
=
{
4
}
color
=
{
ink
}
/>
<Hand
angle
=
{
(
time
.
seconds
/
60
)
*
360
}
length
=
{
94
}
width
=
{
1.5
}
color
=
{
accent
}
tail
=
{
18
}
/>
<View
pointerEvents
=
"none"
style
=
{
{
position
:
'absolute'
,
left
:
CENTER
-
4
,
top
:
CENTER
-
4
,
width
:
8
,
height
:
8
,
borderRadius
:
4
,
backgroundColor
:
accent
,
}
}
/>
</View
>
<Text
size
=
"xl"
weight
=
"700"
>
{
formatTime
(
time
.
minutes
)
}
</Text
>
</Block
>
)
;
}
Semicircle Gauge
Knob.Root with custom ring thickness, contiguous progress, and a pointer hand for instrumentation-style readouts.Loading demo…
import
{
useMemo
,
useState
}
from
'react'
;
import
{
Block
,
Knob
,
Text
}
from
'@platform-blocks/react-ui-library'
;
const
TEMPERATURE_STOPS
=
[
0
,
25
,
50
,
75
,
100
]
;
export
function
Demo
(
)
{
const
[
level
,
setLevel
]
=
useState
(
62
)
;
const
status
=
useMemo
(
(
)
=>
{
if
(
level
>=
85
)
return
'Critical'
;
if
(
level
>=
60
)
return
'Elevated'
;
if
(
level
>=
35
)
return
'Nominal'
;
return
'Idle'
;
}
,
[
level
]
)
;
return
(
<Block
align
=
"center"
>
<Knob.Root
min
=
{
0
}
max
=
{
100
}
value
=
{
level
}
onChange
=
{
setLevel
}
step
=
{
1
}
size
=
{
260
}
appearance
=
{
{
arc
:
{
startAngle
:
-
120
,
sweepAngle
:
240
,
clampInput
:
true
}
,
interaction
:
{
spinStopAtLimits
:
true
}
,
}
}
>
<Knob.Fill
visible
=
{
false
}
/>
<Knob.Ring
thickness
=
{
30
}
color
=
"#0f172a"
trailColor
=
"#1f2937"
radiusOffset
=
{
-
4
}
/>
<Knob.RingSegment
value
=
{
35
}
color
=
"#1e3a8a"
/>
<Knob.RingSegment
value
=
{
25
}
color
=
"#155e75"
/>
<Knob.RingSegment
value
=
{
25
}
color
=
"#854d0e"
/>
<Knob.RingSegment
value
=
{
15
}
color
=
"#7f1d1d"
/>
<Knob.Progress
mode
=
"contiguous"
thickness
=
{
14
}
color
=
"#22d3ee"
roundedCaps
/>
<Knob.TickLayer
source
=
"values"
values
=
{
TEMPERATURE_STOPS
}
shape
=
"line"
length
=
{
22
}
width
=
{
3
}
position
=
"outer"
radiusOffset
=
{
10
}
label
=
{
{
show
:
true
,
formatter
:
(
_
,
index
)
=>
`${TEMPERATURE_STOPS[index]}%`
,
offset
:
24
,
style
:
{
color
:
'#cbd5f5'
,
fontSize
:
12
,
fontWeight
:
'600'
}
,
}
}
/>
<Knob.Pointer
length
=
{
90
}
width
=
{
4
}
color
=
"#f8fafc"
offset
=
{
-
8
}
cap
=
"round"
/>
<Knob.Thumb
visible
=
{
false
}
/>
<Knob.ValueLabel
position
=
"bottom"
formatter
=
{
(
value
)
=>
`${Math.round(value)}% capacity`
}
textStyle
=
{
{
fontSize
:
18
,
fontWeight
:
'600'
,
color
:
'#f8fafc'
}
}
secondary
=
{
{
formatter
:
(
)
=>
status
,
position
:
'bottom'
,
textStyle
:
{
fontSize
:
14
,
color
:
'#94a3b8'
,
marginTop
:
4
}
,
}
}
/>
</Knob.Root
>
<Text
size
=
"sm"
color
=
"secondary"
>
Thermal
headroom ·
{
status
}
</Text
>
</Block
>
)
;
}
Tick Layers
marks, over a finer dot scale from an explicit step list.Loading demo…
import
{
useState
}
from
'react'
;
import
{
Block
,
Knob
}
from
'@platform-blocks/react-ui-library'
;
const
LEVEL_MARKS
=
[
{
value
:
0
,
label
:
'Mute'
}
,
{
value
:
25
,
label
:
'Low'
}
,
{
value
:
50
,
label
:
'Mid'
}
,
{
value
:
75
,
label
:
'High'
}
,
{
value
:
100
,
label
:
'Max'
}
,
]
;
export
function
Demo
(
)
{
const
[
level
,
setLevel
]
=
useState
(
48
)
;
return
(
<Block
align
=
"center"
>
<Knob
value
=
{
level
}
onChange
=
{
setLevel
}
min
=
{
0
}
max
=
{
100
}
step
=
{
1
}
marks
=
{
LEVEL_MARKS
}
size
=
{
180
}
appearance
=
{
{
// A full circle would put the 0 and 100 marks on the same point, overprinting
// their labels; the 270deg arc gives each end of the scale its own position.
arc
:
{
startAngle
:
-
135
,
sweepAngle
:
270
}
,
ring
:
{
thickness
:
16
}
,
fill
:
{
radiusOffset
:
-
24
}
,
progress
:
{
mode
:
'contiguous'
,
color
:
'#f97316'
}
,
ticks
:
[
// Two layers over the same dial: labelled lines from `marks`, and a finer
// dot scale from an explicit step list underneath them.
{
source
:
'marks'
,
shape
:
'line'
,
length
:
16
,
width
:
3
,
position
:
'outer'
,
label
:
{
show
:
true
,
position
:
'outer'
}
,
}
,
{
source
:
'steps'
,
values
:
[
0
,
10
,
20
,
30
,
40
,
50
,
60
,
70
,
80
,
90
,
100
]
,
shape
:
'dot'
,
radiusOffset
:
-
6
,
color
:
'#475569'
,
inactiveColor
:
'rgba(71, 85, 105, 0.4)'
,
}
,
]
,
}
}
valueLabel
=
{
{
formatter
:
(
val
)
=>
`${Math.round(val)}%`
}
}
/>
</Block
>
)
;
}
Interaction modes
appearance.interaction, updating the label as each mode locks in.Loading demo…
import
{
useState
}
from
'react'
;
import
{
Block
,
DataList
,
Knob
,
Row
,
Text
,
useTheme
}
from
'@platform-blocks/react-ui-library'
;
const
MODES
=
[
{
key
:
'spin'
,
name
:
'Spin'
,
detail
:
'Drag in a circular path. Move away from the thumb for finer adjustments.'
,
}
,
{
key
:
'vertical-slide'
,
name
:
'Vertical slide'
,
detail
:
'Grab either side of the knob and drag up or down for mixer-style throws.'
,
}
,
{
key
:
'horizontal-slide'
,
name
:
'Horizontal slide'
,
detail
:
'Start above or below the center, then drag left or right for sideways sweeps.'
,
}
,
{
key
:
'scroll'
,
name
:
'Scroll'
,
detail
:
'Hover with a mouse or trackpad and use the wheel/two-finger scroll.'
,
}
,
]
as
const
;
type
ModeName
=
(
typeof
MODES
)
[
number
]
[
'key'
]
;
const
MODE_LABELS
:
Record
<ModeName
,
string
>
=
MODES
.
reduce
(
(
acc
,
mode
)
=>
{
acc
[
mode
.
key
]
=
mode
.
name
;
return
acc
;
}
,
{
}
as
Record
<ModeName
,
string
>
)
;
export
function
Demo
(
)
{
const
theme
=
useTheme
(
)
;
const
[
value
,
setValue
]
=
useState
(
12
)
;
const
[
activeMode
,
setActiveMode
]
=
useState
<ModeName
|
null
>
(
null
)
;
return
(
<Block
fullWidth
>
<Row
gap
=
"xl"
align
=
"center"
wrap
=
"wrap"
>
<Block
align
=
"center"
>
<Text
size
=
"sm"
weight
=
"500"
>
Multimodal
control
</Text
>
<Knob
value
=
{
value
}
onChange
=
{
setValue
}
min
=
{
-
100
}
max
=
{
100
}
step
=
{
1
}
size
=
{
180
}
behavior
=
"endless"
valueLabel
=
{
{
position
:
'center'
,
formatter
:
(
current
)
=>
`${current > 0 ? '+' : ''}${Math.round(current)}`
,
secondary
:
{
position
:
'bottom'
,
formatter
:
(
)
=>
(
activeMode
?
`${MODE_LABELS[activeMode]} mode`
:
'Try a gesture'
)
,
}
,
}
}
appearance
=
{
{
arc
:
{
startAngle
:
-
135
,
sweepAngle
:
270
,
clampInput
:
true
}
,
ring
:
{
thickness
:
16
,
color
:
'#0f172a'
,
trailColor
:
'#1e293b'
}
,
fill
:
{
color
:
'#020617'
,
radiusOffset
:
-
14
}
,
progress
:
{
mode
:
'split'
,
roundedCaps
:
true
,
thickness
:
10
,
color
:
'#38bdf8'
,
trailColor
:
'#475569'
,
}
,
interaction
:
{
modes
:
MODES
.
map
(
(
mode
)
=>
mode
.
key
)
,
lockThresholdPx
:
32
,
slideRatio
:
1.5
,
variancePx
:
6
,
spinPrecisionRadius
:
80
,
respectStartSide
:
true
,
scroll
:
{
enabled
:
true
,
ratio
:
0.8
,
preventPageScroll
:
true
}
,
onModeChange
:
setActiveMode
,
}
,
}
}
/>
</Block
>
<Block
style
=
{
{
minWidth
:
140
,
flex
:
1
}
}
>
<DataList
spacing
=
"2xl"
labelWidth
=
{
140
}
>
{
MODES
.
map
(
(
mode
)
=>
(
<DataList.Item
key
=
{
mode
.
key
}
>
{
/* The mode currently driving the knob is pulled up to full-contrast text. */
}
<DataList.ItemLabel
color
=
{
activeMode
===
mode
.
key
?
theme
.
text
.
primary
:
theme
.
text
.
muted
}
>
{
mode
.
name
}
</DataList.ItemLabel
>
<DataList.ItemValue
color
=
{
theme
.
text
.
secondary
}
>
{
mode
.
detail
}
</DataList.ItemValue
>
</DataList.Item
>
)
)
}
</DataList
>
</Block
>
</Row
>
</Block
>
)
;
}
</>
react-ui-library
100+ accessible, themeable components that work seamlessly across iOS, Android, and Web.
A Platform Blocks product.
Quick Links
Resources