Size Picker
Product size chips with sold-out sizes struck through, a low-stock note and a Notify me option for sold-out picks.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/size-picker.json'use client'
import { useState } from 'react'
import './size-picker.css'
export interface SizeOption { label: string; stock: number }
export interface SizePickerProps {
sizes: SizeOption[]
onAdd?: (size: string) => void
onNotify?: (size: string) => void
layout?: 'grid' | 'row'
fill?: 'dark' | 'light'
className?: string
}
/** Size chips with stock states. */
export function SizePicker({ sizes, onAdd, onNotify, layout = 'grid', fill = 'dark', className }: SizePickerProps) {
const [sel, setSel] = useState<string | null>(null)
const [done, setDone] = useState('')
const s = sizes.find((x) => x.label === sel)
const out = s ? s.stock === 0 : false
const note = !s ? 'Select a size' : out ? 'Sold out in this size' : s.stock <= 3 ? `Only ${s.stock} left` : 'In stock, ships tomorrow'
return (
<div className={['sd-szp', className].filter(Boolean).join(' ')} data-fill={fill} data-layout={layout}>
<div className="sd-szp__h"><b id="szp-l">Size</b><a href="#size-guide" onClick={(e) => e.preventDefault()}>Size guide</a></div>
<div className="sd-szp__grid" role="radiogroup" aria-labelledby="szp-l">
{sizes.map((z) => (
<button key={z.label} type="button" role="radio" aria-checked={sel === z.label} aria-label={`${z.label}${z.stock === 0 ? ', sold out' : ''}`} className="sd-szp__s" data-out={z.stock === 0 || undefined} onClick={() => { setSel(z.label); setDone('') }}>{z.label}</button>
))}
</div>
<div className="sd-szp__note" aria-live="polite"><span key={note + done}>{done || note}</span></div>
<button type="button" className="sd-szp__cta" data-notify={out || undefined} disabled={!s} onClick={() => { if (!sel) return; if (out) { onNotify?.(sel); setDone(`We’ll email you when ${sel} is back`) } else { onAdd?.(sel); setDone(`Added size ${sel} to your bag`) } }}>
{out ? 'Notify me when it’s back' : 'Add to bag'}
</button>
</div>
)
}.sd-szp { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-szp[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-szp button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-szp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-szp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-szp { width: min(100%, 320px); padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-szp__h { display: flex; justify-content: space-between; font-size: 13.5px; margin-bottom: 10px; }
.sd-szp__h b { font-weight: 600; }
.sd-szp__h a { color: var(--mute); text-decoration: underline; text-underline-offset: 3px; font-size: 12.5px; }
.sd-szp__grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.sd-szp[data-layout='row'] .sd-szp__grid { display: flex; overflow-x: auto; scrollbar-width: none; }
.sd-szp[data-layout='row'] .sd-szp__s { flex: none; width: 56px; }
.sd-szp__s { position: relative; height: 42px; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line); font-size: 13.5px; font-weight: 500; transition: box-shadow 150ms, background-color 150ms, color 150ms, transform 120ms; overflow: hidden; }
.sd-szp__s:hover { box-shadow: inset 0 0 0 1px var(--mute); }
.sd-szp__s:active { transform: scale(0.95); }
.sd-szp__s[aria-checked='true'] { background: var(--strong) !important; color: var(--on); box-shadow: none; }
.sd-szp__s[data-out] { color: var(--mute); }
.sd-szp__s[data-out]::after { content: ''; position: absolute; left: -10%; right: -10%; top: 50%; height: 1px; background: currentColor; transform: rotate(-24deg); opacity: 0.7; }
.sd-szp__s[data-out][aria-checked='true'] { color: var(--on); }
.sd-szp__note { min-height: 20px; margin-top: 10px; font-size: 12.5px; color: var(--mute); }
.sd-szp__note span { animation: sd-szp-in 200ms ease; display: inline-block; }
@keyframes sd-szp-in { from { opacity: 0; transform: translateY(3px); } }
.sd-szp__cta { margin-top: 6px; width: 100%; height: 46px; border-radius: 12px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 14px; transition: background-color 200ms, color 200ms; }
.sd-szp__cta[data-notify] { background: transparent !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-szp__cta:disabled { opacity: 0.4; cursor: default; }
@media (prefers-reduced-motion: reduce) { .sd-szp *, .sd-szp *::before, .sd-szp *::after { animation: none !important; transition: none !important; } }Overview
Sizes are radio chips. Sold-out sizes stay visible but struck through; choosing one swaps the add button for “Notify me when it’s back”. Low stock shows a quiet “Only 2 left”.
A size guide link sits beside the label, where people look for it.
When to use
- Apparel and footwear product pages.
When not to use
- Products with dozens of variants: use a select.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/size-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/size-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/size-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/size-picker.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 size-picker.tsx and size-picker.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 { SizePicker } from '@/components/sundae/size-picker'
<SizePicker />Props
| Prop | Type | Default |
|---|---|---|
sizes*Sizes and stock. | { label: string; stock: number }[] | — |
onAddAdd to bag. | (size: string) => void | — |
onNotifyBack-in-stock request. | (size: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A radiogroup; sold-out sizes remain selectable but are labelled “sold out”.
- Stock notes are announced politely when the size changes.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-szp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Size Picker free?
- Yes. Size Picker is free for personal and commercial projects, with full source on this page.
- How do I install Size Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/size-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Size Picker support keyboard and screen readers?
- A radiogroup; sold-out sizes remain selectable but are labelled “sold out”. Stock notes are announced politely when the size changes.
- Does Size Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Filter Chips
Forms · RefinedMulti Select
Forms · RefinedInvite Members
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined