Jelly Switch
A toggle whose knob squashes on press, springs across the track and wobbles to rest.
npx shadcn@latest add https://sundaeui.dev/r/jelly-switch.json'use client'
import { useId, useState } from 'react'
import './jelly-switch.css'
export interface JellySwitchProps {
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
label: string
/** Hide the visible label (it stays accessible). */
hideLabel?: boolean
description?: string
disabled?: boolean
/** Track colour when on. */
color?: string
name?: string
className?: string
}
/**
* A switch whose knob squashes on press, stretches as it travels and
* wobbles to rest. A native button with role="switch" underneath.
*/
export function JellySwitch({
checked: controlled,
defaultChecked = false,
onCheckedChange,
label,
hideLabel,
description,
disabled,
color,
name,
className,
}: JellySwitchProps) {
const [internal, setInternal] = useState(defaultChecked)
const checked = controlled ?? internal
const [wobble, setWobble] = useState(0)
const id = useId()
const toggle = () => {
const next = !checked
if (controlled === undefined) setInternal(next)
setWobble((w) => w + 1)
onCheckedChange?.(next)
}
return (
<div className={['sd-jelly', className].filter(Boolean).join(' ')} style={color ? ({ '--jelly-on': color } as React.CSSProperties) : undefined}>
<button
type="button"
role="switch"
id={id}
aria-checked={checked}
aria-describedby={description ? `${id}-d` : undefined}
aria-label={hideLabel ? label : undefined}
disabled={disabled}
onClick={toggle}
className="sd-jelly__track"
data-checked={checked || undefined}
>
<span className="sd-jelly__knob" key={wobble} data-wobble={wobble > 0 || undefined}>
<span className="sd-jelly__shine" />
</span>
</button>
{name && <input type="hidden" name={name} value={checked ? 'on' : 'off'} />}
{!hideLabel && (
<label htmlFor={id} className="sd-jelly__text">
<span className="sd-jelly__label">{label}</span>
{description && (
<span id={`${id}-d`} className="sd-jelly__desc">
{description}
</span>
)}
</label>
)}
</div>
)
}/* JellySwitch — Sundae S—008 */
.sd-jelly {
--jelly-w: 56px;
--jelly-h: 32px;
--jelly-pad: 3px;
--jelly-off: #e6e1d8;
--jelly-on: #1fb37a;
--jelly-knob: #ffffff;
--jelly-fg: #23201b;
--jelly-muted: #857e73;
--jelly-knob-size: calc(var(--jelly-h) - var(--jelly-pad) * 2);
--jelly-travel: calc(var(--jelly-w) - var(--jelly-knob-size) - var(--jelly-pad) * 2);
display: inline-flex;
align-items: flex-start;
gap: 12px;
font-family: ui-rounded, 'SF Pro Rounded', ui-sans-serif, system-ui, sans-serif;
color: var(--jelly-fg);
}
:where(.dark) .sd-jelly {
--jelly-off: #34302a;
--jelly-knob: #f6f1e8;
--jelly-fg: #efe9df;
--jelly-muted: #9b9386;
}
.sd-jelly__track {
position: relative;
flex: none;
width: var(--jelly-w);
height: var(--jelly-h);
padding: 0;
border: 0;
border-radius: 999px;
background: var(--jelly-off);
box-shadow: inset 0 1px 3px rgb(0 0 0 / 0.12);
cursor: pointer;
transition: background-color 360ms cubic-bezier(0.22, 1, 0.36, 1);
-webkit-tap-highlight-color: transparent;
}
.sd-jelly__track[data-checked] {
background: var(--jelly-on);
}
.sd-jelly__track:focus-visible {
outline: 2px solid var(--jelly-on);
outline-offset: 3px;
}
.sd-jelly__track:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.sd-jelly__knob {
position: absolute;
top: var(--jelly-pad);
left: var(--jelly-pad);
width: var(--jelly-knob-size);
height: var(--jelly-knob-size);
border-radius: 999px;
background: var(--jelly-knob);
box-shadow:
0 2px 4px rgb(0 0 0 / 0.14),
0 6px 12px -4px rgb(0 0 0 / 0.18);
transform: translateX(0);
transition:
transform 520ms cubic-bezier(0.34, 1.56, 0.64, 1),
width 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.sd-jelly__track[data-checked] .sd-jelly__knob {
transform: translateX(var(--jelly-travel));
}
/* Press: the knob widens toward its destination. */
.sd-jelly__track:active:not(:disabled) .sd-jelly__knob {
width: calc(var(--jelly-knob-size) + 8px);
}
.sd-jelly__track[data-checked]:active:not(:disabled) .sd-jelly__knob {
transform: translateX(calc(var(--jelly-travel) - 8px));
}
/* Release: a short jelly wobble. */
.sd-jelly__knob[data-wobble] {
animation: sd-jelly-wobble 620ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes sd-jelly-wobble {
0% {
scale: 1.18 0.84;
}
35% {
scale: 0.9 1.1;
}
60% {
scale: 1.05 0.96;
}
100% {
scale: 1 1;
}
}
.sd-jelly__shine {
position: absolute;
top: 18%;
left: 22%;
width: 34%;
height: 22%;
border-radius: 50%;
background: rgb(255 255 255 / 0.9);
filter: blur(1px);
opacity: 0.7;
}
.sd-jelly__text {
display: grid;
gap: 2px;
padding-top: 4px;
cursor: pointer;
}
.sd-jelly__label {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.sd-jelly__desc {
font-size: 13px;
color: var(--jelly-muted);
}
@media (prefers-reduced-motion: reduce) {
.sd-jelly__knob {
transition-duration: 1ms;
animation: none !important;
}
}Overview
Press and hold: the knob widens toward where it is about to go, as a finger would push it. Let go and it springs across the track with a little overshoot, then wobbles. It squashes, stretches and settles in about half a second.
Under the motion it is a plain, accessible switch: a native button with role="switch" and aria-checked, a real <label>, optional description text, and a hidden input so it posts with a form.
Good for settings screens and onboarding preferences in consumer products, anywhere a toggle can afford some personality.
When to use
- Collecting input where the control itself should guide people.
When not to use
- Cases a plain native input already serves well.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/jelly-switch.jsonnpx shadcn@latest add https://sundaeui.dev/r/jelly-switch.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/jelly-switch.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/jelly-switch.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy jelly-switch.tsx and jelly-switch.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { JellySwitch } from '@/components/sundae/jelly-switch'
<JellySwitch
label="Weekly digest"
description="One calm email every Sunday."
defaultChecked
onCheckedChange={(on) => savePreference('digest', on)}
color="#ff7a45"
/>Props
| Prop | Type | Default |
|---|---|---|
label*Visible label, which is also the accessible name. | string | — |
descriptionSecondary text linked with aria-describedby. | string | — |
checkedControlled state. | boolean | — |
defaultCheckedInitial state for uncontrolled use. | boolean | false |
onCheckedChangeCalled on toggle. | (checked: boolean) => void | — |
colorTrack colour when on. | string | — |
hideLabelHide the label visually. It is applied as aria-label instead. | boolean | — |
nameAdds a hidden input ("on" or "off") for native form posts. | string | — |
disabledDisables interaction. | boolean | — |
Accessibility
- A native <button role="switch"> with aria-checked. Space and Enter toggle it.
- The label is a real <label for>, so clicking the text toggles the switch too.
- With reduced motion, the wobble is removed and travel is instant.
Customization
- Size with --jelly-w and --jelly-h. The knob size and travel are derived from them.
- Use --jelly-off, --jelly-on and --jelly-knob for colours.
FAQ
- Is Jelly Switch free?
- Yes. Jelly Switch is free for personal and commercial projects, with full source on this page.
- How do I install Jelly Switch?
- Run npx shadcn@latest add https://sundaeui.dev/r/jelly-switch.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Jelly Switch support keyboard and screen readers?
- A native <button role="switch"> with aria-checked. Space and Enter toggle it. The label is a real <label for>, so clicking the text toggles the switch too.
- Does Jelly Switch respect reduced motion?
- With reduced motion, the wobble is removed and travel is instant.
More like this
All forms →- Pro
Onboarding Flow
Forms · Refined Rolling Counter
Forms · PlayfulOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined Date Picker
Forms · UnderstatedCombobox
Forms · Understated