ToolbarDemo
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Toggle } from '@/components/ui/toggle'
import { Toolbar, ToolbarItem } from '@/components/ui/toolbar'
export function ToolbarDemo() {
return (
<div className='flex w-full flex-wrap items-center justify-center gap-x-8 gap-y-10 overflow-x-auto p-3'>
<ToolbarNavigationDemo />
<ToolbarLabelDemo />
<ToolbarFormattingDemo />
<ToolbarCallDemo />
<div className='w-full max-w-sm'>
<ToolbarDockedDemo />
</div>
</div>
)
}
export function ToolbarNavigationDemo() {
return (
<Toolbar>
<ToolbarItem>
<i className='icon-[material-symbols--arrow-back-rounded]' />
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--arrow-forward-rounded]' />
</ToolbarItem>
<ToolbarItem asChild>
<Button>
<i className='icon-[material-symbols--add-rounded]' />
</Button>
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--picture-in-picture-rounded]' />
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--more-vert]' />
</ToolbarItem>
</Toolbar>
)
}
export function ToolbarLabelDemo({ compact = false }: { compact?: boolean }) {
const [view, setView] = useState('photos')
return (
<Toolbar compact={compact}>
<ToolbarItem asChild>
<Toggle variant='tonal' pressed={view === 'photos'} onPressedChange={() => setView('photos')}>
<i className='icon-[material-symbols--image-rounded]' />
Photos
</Toggle>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle variant='tonal' pressed={view === 'memories'} onPressedChange={() => setView('memories')}>
Memories
</Toggle>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle variant='tonal' pressed={view === 'library'} onPressedChange={() => setView('library')}>
Library
</Toggle>
</ToolbarItem>
</Toolbar>
)
}
export function ToolbarFormattingDemo({ compact = false }: { compact?: boolean }) {
return (
<Toolbar color='vibrant' compact={compact}>
<ToolbarItem asChild>
<Toggle icon variant='tonal' defaultPressed>
<i className='icon-[material-symbols--format-bold-rounded]' />
</Toggle>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle icon variant='tonal'>
<i className='icon-[material-symbols--format-italic-rounded]' />
</Toggle>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle icon variant='tonal'>
<i className='icon-[material-symbols--format-underlined-rounded]' />
</Toggle>
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--format-color-text-rounded]' />
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--format-color-fill-rounded]' />
</ToolbarItem>
</Toolbar>
)
}
export function ToolbarCallDemo({ compact = false }: { compact?: boolean }) {
const [inCall, setInCall] = useState(true)
const [cameraOn, setCameraOn] = useState(false)
const [muted, setMuted] = useState(false)
return (
<div className='flex shrink-0 items-center gap-2'>
<Toolbar compact={compact}>
<ToolbarItem asChild>
<Toggle icon variant='tonal' pressed={cameraOn} onPressedChange={setCameraOn} disabled={!inCall}>
<i
className={
cameraOn
? 'icon-[material-symbols--videocam-rounded]'
: 'icon-[material-symbols--videocam-off-rounded]'
}
/>
</Toggle>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle icon variant='tonal' pressed={muted} onPressedChange={setMuted} disabled={!inCall}>
<i
className={
muted ? 'icon-[material-symbols--mic-off-rounded]' : 'icon-[material-symbols--mic-rounded]'
}
/>
</Toggle>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle icon variant='tonal' defaultPressed disabled={!inCall}>
<i className='icon-[material-symbols--front-hand-rounded]' />
</Toggle>
</ToolbarItem>
<ToolbarItem disabled={!inCall}>
<i className='icon-[material-symbols--more-vert]' />
</ToolbarItem>
</Toolbar>
<Button
type='button'
icon
square
size='lg'
className='bg-error text-on-error shadow-elevation-3'
onClick={() => setInCall(!inCall)}
>
<i className={inCall ? 'icon-[material-symbols--call-end]' : 'icon-[material-symbols--call]'} />
</Button>
</div>
)
}
export function ToolbarDockedDemo() {
return (
<Toolbar variant='docked'>
<ToolbarItem>
<i className='icon-[material-symbols--archive-rounded]' />
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--delete-outline-rounded]' />
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--mark-email-unread-outline-rounded]' />
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--snooze-rounded]' />
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--star-outline-rounded]' />
</ToolbarItem>
</Toolbar>
)
}
Installation
- Install the following dependency
pnpm add @radix-ui/react-slot- Add the Ripple component. For composed actions, also add Button and Toggle.
- Copy and paste the following code into your project
src/components/ui/toolbar.tsx
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Ripple } from './ripple'
const toolbarItemStyles = [
'[&_[data-slot=toolbar-item]>*]:relative [&_[data-slot=toolbar-item]>*]:inline-flex [&_[data-slot=toolbar-item]>*]:h-10 [&_[data-slot=toolbar-item]>*]:shrink-0 [&_[data-slot=toolbar-item]>*]:items-center [&_[data-slot=toolbar-item]>*]:justify-center [&_[data-slot=toolbar-item]>*]:rounded-full [&_[data-slot=toolbar-item]>*]:border-0 [&_[data-slot=toolbar-item]>*]:py-0 [&_[data-slot=toolbar-item]>*]:shadow-none',
'[&_[data-slot=toolbar-item]>[data-icon=true]]:w-10 [&_[data-slot=toolbar-item]>[data-icon=true]]:min-w-10 [&_[data-slot=toolbar-item]>[data-icon=true]]:px-0 [&_[data-slot=toolbar-item]>[data-slot=toolbar-button]]:w-10 [&_[data-slot=toolbar-item]>[data-slot=toolbar-button]]:min-w-10 [&_[data-slot=toolbar-item]>[data-slot=toolbar-button]]:px-0',
'[&_[data-slot=toolbar-item]>*>i]:pointer-events-none [&_[data-slot=toolbar-item]>*>i]:size-6! [&_[data-slot=toolbar-item]>*>i]:shrink-0 [&_[data-slot=toolbar-item]>*>svg]:pointer-events-none [&_[data-slot=toolbar-item]>*>svg]:size-6! [&_[data-slot=toolbar-item]>*>svg]:shrink-0',
'[&_[data-slot=toolbar-item]>*:not(:disabled)]:bg-transparent [&_[data-slot=toolbar-item]>*:not(:disabled)]:text-[var(--toolbar-foreground,var(--on-surface-variant))]',
'[&_[data-slot=toolbar-item]>[data-slot=button][data-variant=default]:not(:disabled)]:bg-primary [&_[data-slot=toolbar-item]>[data-slot=button][data-variant=default]:not(:disabled)]:text-on-primary',
'[&_[data-slot=toolbar-item]>[data-slot=button][data-variant=tonal]:not(:disabled)]:bg-[var(--toolbar-selected,var(--secondary-container))] [&_[data-slot=toolbar-item]>[data-slot=button][data-variant=tonal]:not(:disabled)]:text-[var(--toolbar-on-selected,var(--on-secondary-container))]',
'[&_[data-slot=toolbar-item]>[data-slot=toggle][data-state=on]:not(:disabled)]:bg-[var(--toolbar-selected,var(--secondary-container))] [&_[data-slot=toolbar-item]>[data-slot=toggle][data-state=on]:not(:disabled)]:text-[var(--toolbar-on-selected,var(--on-secondary-container))]',
'[&_[data-slot=toolbar-item]>[data-slot]:disabled]:bg-on-surface/10 [&_[data-slot=toolbar-item]>[data-slot]:disabled]:text-on-surface/38',
'[&_[data-slot=toolbar-item]>*:focus-visible]:outline-2 [&_[data-slot=toolbar-item]>*:focus-visible]:outline-offset-2 [&_[data-slot=toolbar-item]>*:focus-visible]:outline-primary',
// State layers follow the adapted foreground, including selected toggles.
'[&_[data-slot=toolbar-item]>*>[data-slot$=-layer]]:pointer-events-none [&_[data-slot=toolbar-item]>*>[data-slot$=-layer]]:bg-current! [&_[data-slot=toolbar-item]>*>[data-slot$=-layer]]:opacity-0',
'[&_[data-slot=toolbar-item]>*:active:not(:disabled)>[data-slot$=-layer]]:opacity-10 [&_[data-slot=toolbar-item]>*:hover:not(:active):not(:disabled)>[data-slot$=-layer]]:opacity-8',
]
const toolbarVariants = cva(['inline-flex shrink-0 items-center', toolbarItemStyles], {
variants: {
variant: {
docked: 'h-16 w-full justify-between px-4',
float: 'w-fit gap-1 rounded-full p-2 shadow-elevation-3',
},
color: {
standard:
'bg-surface-container [--toolbar-foreground:var(--on-surface-variant)] [--toolbar-on-selected:var(--on-secondary-container)] [--toolbar-selected:var(--secondary-container)]',
vibrant:
'bg-primary-container [--toolbar-foreground:var(--on-primary-container)] [--toolbar-on-selected:var(--on-surface)] [--toolbar-selected:var(--surface-container)]',
},
orientation: {
horizontal: '',
vertical: '',
},
compact: {
true: '[&_[data-slot=toolbar-item]]:h-10 [&_[data-slot=toolbar-item]]:min-w-10 [&_[data-slot=toolbar-item]]:p-0',
false: '',
},
},
compoundVariants: [
{ variant: 'float', orientation: 'horizontal', className: 'h-16 flex-row' },
{ variant: 'float', orientation: 'vertical', className: 'h-fit min-h-16 w-16 flex-col' },
{
variant: 'float',
orientation: 'horizontal',
compact: true,
className: 'h-14 gap-1 p-2',
},
{
variant: 'float',
orientation: 'vertical',
compact: true,
className: 'min-h-14 w-14 gap-1 p-2',
},
{ variant: 'docked', compact: true, className: 'h-14 px-2' },
],
defaultVariants: {
variant: 'float',
color: 'standard',
orientation: 'horizontal',
compact: false,
},
})
function Toolbar({
className,
variant = 'float',
color = 'standard',
orientation = 'horizontal',
compact = false,
...props
}: Omit<React.ComponentProps<'div'>, 'color'> & VariantProps<typeof toolbarVariants>) {
const resolvedOrientation = variant === 'docked' ? 'horizontal' : orientation
return (
<div
role='group'
data-slot='toolbar'
data-variant={variant}
data-color={color}
data-orientation={resolvedOrientation}
data-compact={compact}
className={cn(toolbarVariants({ variant, color, orientation: resolvedOrientation, compact }), className)}
{...props}
/>
)
}
function ToolbarItem({
className,
asChild = false,
children,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button'
return (
<div
data-slot='toolbar-item'
className='inline-flex h-12 min-w-12 shrink-0 items-center justify-center p-1'
>
<Comp
{...(!asChild ? { type: 'button' as const, 'data-slot': 'toolbar-button' } : {})}
className={cn(
'cursor-pointer text-sm font-medium whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:bg-on-surface/10 disabled:text-on-surface/38',
className,
)}
{...props}
>
{asChild ? (
children
) : (
<>
{children}
<span data-slot='toolbar-button-layer' className='absolute inset-0 rounded-[inherit] transition-all' />
<Ripple disabled={props.disabled} />
</>
)}
</Comp>
</div>
)
}
export { Toolbar, ToolbarItem }
Usage
import { Toolbar, ToolbarItem } from '@/components/ui/toolbar'
import { Button } from '@/components/ui/button'
import { Toggle } from '@/components/ui/toggle'<Toolbar>
<ToolbarItem>
<i className='icon-[material-symbols--undo-rounded]' />
</ToolbarItem>
<ToolbarItem asChild>
<Button icon>
<i className='icon-[material-symbols--add-rounded]' />
</Button>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle icon variant='tonal'>
<i className='icon-[material-symbols--format-bold-rounded]' />
</Toggle>
</ToolbarItem>
</Toolbar>Place ToolbarItem inside Toolbar. Use asChild to wrap a Button or Toggle, and add icon for an icon-only control.
Orientation
Set orientation to vertical or horizontal on a floating Toolbar.
ToolbarVerticalDemo
import { Toggle } from '@/components/ui/toggle'
import { Toolbar, ToolbarItem } from '@/components/ui/toolbar'
export function ToolbarVerticalDemo() {
return (
<div className='flex max-w-full flex-wrap items-start justify-center gap-8'>
{(['vertical', 'horizontal'] as const).map(orientation => (
<Toolbar key={orientation} orientation={orientation}>
<ToolbarItem asChild>
<Toggle icon variant='tonal' defaultPressed>
<i className='icon-[material-symbols--format-bold-rounded]' />
</Toggle>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle icon variant='tonal'>
<i className='icon-[material-symbols--format-italic-rounded]' />
</Toggle>
</ToolbarItem>
<ToolbarItem asChild>
<Toggle icon variant='tonal'>
<i className='icon-[material-symbols--format-underlined-rounded]' />
</Toggle>
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--format-color-text-rounded]' />
</ToolbarItem>
<ToolbarItem>
<i className='icon-[material-symbols--format-color-fill-rounded]' />
</ToolbarItem>
</Toolbar>
))}
</div>
)
}
Compact
Add compact for a 56px-high horizontal toolbar or a 56px-wide vertical toolbar, keeping control sizes unchanged. Floating toolbars use 8px padding and 4px gaps.
ToolbarCompactDemo
import { ToolbarCallDemo, ToolbarFormattingDemo } from './toolbar-demo'
export function ToolbarCompactDemo() {
return (
<div className='flex w-full flex-col gap-8 overflow-x-auto p-3'>
<div className='flex flex-wrap items-center justify-center gap-6'>
<ToolbarFormattingDemo />
<ToolbarFormattingDemo compact />
</div>
<div className='flex flex-wrap items-center justify-center gap-6'>
<ToolbarCallDemo />
<ToolbarCallDemo compact />
</div>
</div>
)
}
API Reference
Toolbar
| Prop | Type | Default |
|---|---|---|
| variant | docked | float | float |
| color | standard | vibrant | standard |
| orientation | horizontal | vertical (float only) | horizontal |
| compact | boolean | false |
| children | React.ReactNode | |
| className | string |
All other div props and refs are forwarded to the container.
ToolbarItem
| Prop | Type | Default |
|---|---|---|
| asChild | boolean | false |
| children | React.ReactNode (one element when asChild) | |
| disabled | boolean | false |
| type | button | submit | reset | button (native control only) |
| className | string (inner control) |
All other button props and refs are forwarded to the inner control. With asChild, the child retains its own element type and behavior.