import { BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonDemo () {
return (
< div className = "flex gap-1" >
< ToggleButton aria-label = "Bold" defaultSelected >
< BoldIcon />
</ ToggleButton >
< ToggleButton aria-label = "Italic" >
< ItalicIcon />
</ ToggleButton >
< ToggleButton aria-label = "Underline" >
< UnderlineIcon />
</ ToggleButton >
</ div >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/toggle-button
The CLI installs dependencies and any other components this one uses.
import { ToggleButton } from "@/components/ui/toggle-button" ;
< ToggleButton aria-label = "Bold" defaultSelected >
< BoldIcon />
</ ToggleButton >
The label describes the action, not the state
A toggle button is announced with aria-pressed, so screen readers already
say whether it is on. Keep the label constant ("Mute", "Bold") and let the
pressed state carry the meaning. Swapping the label to "Unmute" while it is
pressed reads as "Unmute, pressed", which is ambiguous.
Toggle button — an on/off state that applies immediately and persists, usually in a toolbar: bold, mute, pin, star.
Toggle Button Group — a set of related toggles, with single selection (a segmented control) or multiple selection (a formatting bar).
Switch — a labelled on/off setting in a form or settings list.
Checkbox — a choice that is submitted with a form.
Button — a one-off action with no persistent state.
A toggle button is a single element. Icons and text are plain children, sized and spaced for you.
< ToggleButton >
< Icon /> { /* optional, auto-sized */ }
Label
</ ToggleButton >
Part Renders Notes ToggleButton<button aria-pressed>Root. Carries data-slot="toggle-button" and all state attributes. Inside a single-selection group it becomes role="radio" with aria-checked. Icon <svg>Sized size-4 (size-3 / size-3.5 for xs / sm) unless you pass a size-* class.
default is borderless and fills with the accent color when selected, which suits toolbars. outline has a border and turns brand-tinted when selected, which reads better as a standalone control. A third variant, segmented, is applied automatically inside a segmented ToggleButtonGroup .
import { EyeIcon, StarIcon } from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonVariants () {
return (
< div className = "flex flex-wrap items-center gap-6" >
< div className = "flex flex-col items-center gap-2" >
< ToggleButton defaultSelected >
< EyeIcon /> Preview
</ ToggleButton >
< span className = "text-muted-foreground text-xs" >default</ span >
</ div >
< div className = "flex flex-col items-center gap-2" >
< ToggleButton variant = "outline" defaultSelected >
< StarIcon /> Starred
</ ToggleButton >
< span className = "text-muted-foreground text-xs" >outline</ span >
</ div >
</ div >
);
}
Put an icon before the label for standalone toggles like Star, Pin or Watch. The label keeps the control understandable without a tooltip.
Star Pin to sidebar Watch
import { BellIcon, PinIcon, StarIcon } from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonOutline () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< ToggleButton variant = "outline" defaultSelected >
< StarIcon /> Star
</ ToggleButton >
< ToggleButton variant = "outline" >
< PinIcon /> Pin to sidebar
</ ToggleButton >
< ToggleButton variant = "outline" >
< BellIcon /> Watch
</ ToggleButton >
</ div >
);
}
md (32px) is the default. xs and sm fit dense toolbars and table rows; lg suits touch-first controls like call or player bars. Every size has a matching minimum width, so icon-only toggles stay square.
import { PinIcon } from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonSizes () {
return (
< div className = "flex items-center gap-2" >
{([ "xs" , "sm" , "md" , "lg" ] as const ). map (( size ) => (
< ToggleButton
key = {size}
variant = "outline"
size = {size}
aria-label = { `Pin (${ size })` }
>
< PinIcon />
</ ToggleButton >
))}
</ div >
);
}
Use isSelected and onChange to own the state, for example to persist it or to react to it elsewhere on the page. Use defaultSelected when the button can manage itself.
Notifications for #design are on.
import { BellIcon, BellOffIcon } from "lucide-react" ;
import { useState } from "react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonControlled () {
const [ isMuted , setMuted ] = useState ( false );
return (
< div className = "flex flex-col items-center gap-3" >
< ToggleButton
variant = "outline"
isSelected = {isMuted}
onChange = {setMuted}
aria-label = "Mute notifications"
>
{isMuted ? < BellOffIcon /> : < BellIcon />}
</ ToggleButton >
< p className = "text-muted-foreground text-sm" >
Notifications for #design are {isMuted ? "muted" : "on" }.
</ p >
</ div >
);
}
children and className accept a function of the button's state. Use it to fill or swap the icon for the selected state without lifting state up, while the label stays the same.
import { EyeIcon, EyeOffIcon, StarIcon } from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonRenderProps () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< ToggleButton variant = "outline" >
{({ isSelected }) => (
<>
< StarIcon
className = {isSelected ? "size-4 fill-current" : "size-4" }
/>
Star
</>
)}
</ ToggleButton >
< ToggleButton variant = "outline" aria-label = "Hide archived projects" >
{({ isSelected }) => (isSelected ? < EyeOffIcon /> : < EyeIcon />)}
</ ToggleButton >
</ div >
);
}
Icon-only toggles need an aria-label. Wrap them in a TooltipTrigger so mouse users see the same label.
import { MicIcon, MicOffIcon, VideoIcon, VideoOffIcon } from "lucide-react" ;
import { useState } from "react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip" ;
export default function ToggleButtonTooltip () {
const [ micOff , setMicOff ] = useState ( false );
const [ cameraOff , setCameraOff ] = useState ( true );
return (
< div className = "flex items-center gap-1" >
< TooltipTrigger >
< ToggleButton
size = "lg"
aria-label = "Mute microphone"
isSelected = {micOff}
onChange = {setMicOff}
>
{micOff ? < MicOffIcon /> : < MicIcon />}
</ ToggleButton >
< Tooltip >Mute microphone</ Tooltip >
</ TooltipTrigger >
< TooltipTrigger >
< ToggleButton
size = "lg"
aria-label = "Turn off camera"
isSelected = {cameraOff}
onChange = {setCameraOff}
>
{cameraOff ? < VideoOffIcon /> : < VideoIcon />}
</ ToggleButton >
< Tooltip >Turn off camera</ Tooltip >
</ TooltipTrigger >
</ div >
);
}
isDisabled removes the button from the tab order and keeps its current state visible, so users can still see whether it is on.
import { LockIcon, WifiIcon } from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonDisabled () {
return (
< div className = "flex flex-wrap items-center gap-2" >
< ToggleButton variant = "outline" isDisabled >
< WifiIcon /> Offline mode
</ ToggleButton >
< ToggleButton variant = "outline" isDisabled defaultSelected >
< LockIcon /> Locked
</ ToggleButton >
</ div >
);
}
Style the selected state with Tailwind's data-selected: variant. Here the icon fills in and takes the destructive or brand color instead of the default accent background.
import { BookmarkIcon, HeartIcon } from "lucide-react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonCustomStyle () {
return (
< div className = "flex items-center gap-1" >
< ToggleButton
aria-label = "Like"
className = "data-selected:bg-destructive/10 data-selected:text-destructive data-selected:[&_svg]:fill-current"
>
< HeartIcon />
</ ToggleButton >
< ToggleButton
aria-label = "Save"
defaultSelected
className = "data-selected:bg-transparent data-selected:text-brand data-selected:[&_svg]:fill-current"
>
< BookmarkIcon />
</ ToggleButton >
</ div >
);
}
A like toggle with a live count and a save toggle, next to a regular comment button. The accessible label includes the count so it's announced too.
Shipped the new onboarding checklist today. Activation is up 14% in the first week — huge thanks to everyone who tested the drafts.
import { BookmarkIcon, HeartIcon, MessageCircleIcon } from "lucide-react" ;
import { useState } from "react" ;
import { Avatar } from "@/components/ui/avatar" ;
import { Button } from "@/components/ui/button" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonRecipePostActions () {
const [ liked , setLiked ] = useState ( false );
const likes = 128 + (liked ? 1 : 0 );
return (
< article className = "w-full max-w-md rounded-xl border bg-card p-4" >
< header className = "flex items-center gap-3" >
< Avatar alt = "Maya Chen" fallback = "MC" colorful />
< div className = "min-w-0 flex-1" >
< p className = "font-medium text-sm" >Maya Chen</ p >
< p className = "text-muted-foreground text-xs" >2 hours ago</ p >
</ div >
</ header >
< p className = "mt-3 text-sm leading-relaxed" >
Shipped the new onboarding checklist today. Activation is up 14% in the
first week — huge thanks to everyone who tested the drafts.
</ p >
< div className = "-ml-2 mt-3 flex items-center gap-1" >
< ToggleButton
size = "sm"
isSelected = {liked}
onChange = {setLiked}
aria-label = { `Like, ${ likes } likes` }
className = "tabular-nums data-selected:bg-destructive/10 data-selected:text-destructive data-selected:[&_svg]:fill-current"
>
< HeartIcon /> {likes}
</ ToggleButton >
< Button variant = "ghost" size = "sm" className = "text-muted-foreground" >
< MessageCircleIcon /> 24
</ Button >
< ToggleButton
size = "sm"
aria-label = "Save post"
className = "ml-auto data-selected:bg-transparent data-selected:text-brand data-selected:[&_svg]:fill-current"
>
< BookmarkIcon />
</ ToggleButton >
</ div >
</ article >
);
}
A compact player bar mixing toggle buttons (like, shuffle, play/pause, repeat) with plain buttons (previous, next). The play toggle uses a render prop to swap icons.
Midnight Drive
Lumen Avenue · Night Transit
import {
HeartIcon,
PauseIcon,
PlayIcon,
Repeat1Icon,
ShuffleIcon,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
export default function ToggleButtonRecipePlayer () {
return (
< div className = "flex w-full max-w-sm items-center gap-3 rounded-xl border bg-card p-3" >
< div className = "size-11 shrink-0 rounded-md bg-linear-to-br from-brand/80 to-primary/60" />
< div className = "min-w-0 flex-1" >
< p className = "truncate font-medium text-sm" >Midnight Drive</ p >
< p className = "truncate text-muted-foreground text-xs" >
Lumen Avenue · Night Transit
</ p >
</ div >
< ToggleButton
size = "sm"
aria-label = "Add to liked songs"
className = "data-selected:bg-transparent data-selected:text-brand data-selected:[&_svg]:fill-current"
>
< HeartIcon />
</ ToggleButton >
< div className = "flex items-center" >
< ToggleButton size = "sm" aria-label = "Shuffle" >
< ShuffleIcon />
</ ToggleButton >
< Button variant = "ghost" size = "icon-sm" aria-label = "Previous track" >
< SkipBackIcon />
</ Button >
< ToggleButton size = "sm" aria-label = "Play" defaultSelected >
{({ isSelected }) => (isSelected ? < PauseIcon /> : < PlayIcon />)}
</ ToggleButton >
< Button variant = "ghost" size = "icon-sm" aria-label = "Next track" >
< SkipForwardIcon />
</ Button >
< ToggleButton size = "sm" aria-label = "Repeat track" >
< Repeat1Icon />
</ ToggleButton >
</ div >
</ div >
);
}
Rounded outline toggles that each switch a filter on or off, driving a filtered list. Use independent toggles like this when filters combine freely; use a ToggleButtonGroup with selectionMode="multiple" when you want the group to be one keyboard stop.
Unread Starred Mentions Has attachmentPriya Raman Q3 roadmap review Jonas Weber @you can you check the invoice?
import {
AtSignIcon,
CircleDotIcon,
PaperclipIcon,
StarIcon,
} from "lucide-react" ;
import { useState } from "react" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
type FilterId = "unread" | "starred" | "mentions" | "attachments" ;
const filters : { id : FilterId ; label : string ; icon : typeof StarIcon }[] = [
{ id: "unread" , label: "Unread" , icon: CircleDotIcon },
{ id: "starred" , label: "Starred" , icon: StarIcon },
{ id: "mentions" , label: "Mentions" , icon: AtSignIcon },
{ id: "attachments" , label: "Has attachment" , icon: PaperclipIcon },
];
const messages = [
{
from: "Priya Raman" ,
subject: "Q3 roadmap review" ,
unread: true ,
starred: true ,
mentions: false ,
attachments: true ,
},
{
from: "Jonas Weber" ,
subject: "@you can you check the invoice?" ,
unread: true ,
starred: false ,
mentions: true ,
attachments: false ,
},
{
from: "Ana Souza" ,
subject: "Design crit notes" ,
unread: false ,
starred: true ,
mentions: false ,
attachments: true ,
},
{
from: "Leo Park" ,
subject: "Lunch on Friday?" ,
unread: false ,
starred: false ,
mentions: false ,
attachments: false ,
},
];
export default function ToggleButtonRecipeFilterChips () {
const [ active , setActive ] = useState < Set < FilterId >>( new Set ([ "unread" ]));
const visible = messages. filter (( m ) => [ ... active]. every (( f ) => m[f]));
const toggle = ( id : FilterId , on : boolean ) =>
setActive (( prev ) => {
const next = new Set (prev);
if (on) next. add (id);
else next. delete (id);
return next;
});
return (
< div className = "w-full max-w-md space-y-3" >
< div className = "flex flex-wrap gap-1.5" >
{filters. map (( f ) => (
< ToggleButton
key = {f.id}
variant = "outline"
size = "sm"
className = "rounded-full"
isSelected = {active. has (f.id)}
onChange = {( on ) => toggle (f.id, on)}
>
< f.icon /> {f.label}
</ ToggleButton >
))}
</ div >
< ul className = "divide-y rounded-lg border bg-card text-sm" >
{visible. map (( m ) => (
< li key = {m.subject} className = "flex items-center gap-3 px-3 py-2.5" >
< span
className = {
m.unread
? "size-2 shrink-0 rounded-full bg-brand"
: "size-2 shrink-0"
}
/>
< span className = "w-24 shrink-0 truncate font-medium" >{m.from}</ span >
< span className = "truncate text-muted-foreground" >{m.subject}</ span >
</ li >
))}
{visible. length === 0 && (
< li className = "px-3 py-6 text-center text-muted-foreground" >
No messages match these filters.
</ li >
)}
</ ul >
</ div >
);
}
Renders a native <button> with aria-pressed, so it is announced as a toggle button along with its pressed state.
Inside a single-selection ToggleButtonGroup, React Aria switches it to role="radio" with aria-checked.
Icon-only toggles must have an aria-label (or aria-labelledby). Keep the label the same in both states.
The focus ring only appears for keyboard focus (data-focus-visible).
isDisabled sets the native disabled attribute and removes the button from the tab order.
Key Action Tab Moves focus to the button Space / Enter Toggles the button
Style states with Tailwind's data variants, e.g. data-selected:text-brand.
Attribute Present when data-selectedThe button is on data-hoveredHovered with a mouse or pen (never on touch) data-pressedBeing pressed data-focusedFocused by any means data-focus-visibleFocused with the keyboard data-disabledisDisabled is true (directly or from the group)data-slot="toggle-button"Always
className and children also accept a function of the button's state:
< ToggleButton
aria-label = "Mute"
className = {({ isSelected }) => (isSelected ? "text-destructive" : "" )}
>
{({ isSelected }) => (isSelected ? < VolumeXIcon /> : < Volume2Icon />)}
</ ToggleButton >
The tailwind-variants function behind the component. Use it to give another element the toggle look, or to extend the variants.
import { toggleVariants } from "@/components/ui/toggle-button" ;
toggleVariants ({ variant: "outline" , size: "sm" });
ToggleGroupStyleContext is also exported. ToggleButtonGroup uses it to pass variant and size down; props set directly on a ToggleButton win over the context.
Also accepts every prop of React Aria's ToggleButton , including focus and keyboard handlers.