Toggle
A two-state button that can be either on or off.
import { css } from 'remix/component'
import { BookmarkIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleDemo() {import { css } from 'remix/component'
import { BookmarkIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleDemo() {
return () => (
<Toggle
aria-label="Toggle bookmark"
size="sm"
variant="outline"
mix={css({
'&[data-state="on"]': { backgroundColor: 'transparent' },
'&[data-state="on"] > svg': {
fill: 'oklch(62.3% 0.214 259.815)',
stroke: 'oklch(62.3% 0.214 259.815)',
},
})}
>
<BookmarkIcon />
Bookmark
</Toggle>
)
}Installation
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/toggle.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/toggle.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/toggle.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/toggle.jsonInstall the following dependencies:
$ bun add remix$ npm install remix$ pnpm add remix$ yarn add remixCopy and paste the following code into your project.
import { css, on, type CSSMixinDescriptor, type Handle, type Props } from 'remix/component'
export type ToggleVariant = 'default' | 'outline'
export type ToggleSize = 'default' | 'sm' | 'lg'
export interface ToggleVariantOptions {
variant?: ToggleVariant
size?: ToggleSize
}
export interface ToggleProps extends Props<'button'>, ToggleVariantOptions {
/** Controlled pressed state. */
pressed?: boolean
/** Initial pressed state when uncontrolled. */
defaultPressed?: boolean
onPressedChange?: (pressed: boolean) => void
}
const base = css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
borderRadius: 'calc(var(--radius) - 2px)',
// `bg-transparent` of both variants lives here so that `data-[state=on]:bg-accent` beats it.
backgroundColor: 'transparent',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
fontWeight: 500,
whiteSpace: 'nowrap',
transitionProperty: 'color, box-shadow',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '150ms',
outlineStyle: 'none',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
'&:hover': { backgroundColor: 'var(--muted)', color: 'var(--muted-foreground)' },
'&:focus-visible': {
borderColor: 'var(--ring)',
'--tw-ring-shadow':
'0 0 0 3px var(--tw-ring-color, color-mix(in oklab, var(--ring) 50%, transparent))',
},
'&:disabled': { pointerEvents: 'none', opacity: 0.5 },
'&[aria-invalid="true"]': {
borderColor: 'var(--destructive)',
'--tw-ring-color': 'color-mix(in oklab, var(--destructive) 20%, transparent)',
},
'&[data-state="on"]': { backgroundColor: 'var(--accent)', color: 'var(--accent-foreground)' },
'.dark &[aria-invalid="true"]': {
'--tw-ring-color': 'color-mix(in oklab, var(--destructive) 40%, transparent)',
},
'& svg': { pointerEvents: 'none', flexShrink: 0 },
'& svg:not([class*="size-"])': { width: '1rem', height: '1rem' },
})
const variants: Partial<Record<ToggleVariant, CSSMixinDescriptor>> = {
outline: css({
borderWidth: '1px',
borderColor: 'var(--input)',
'--tw-shadow': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'&:hover': { backgroundColor: 'var(--accent)', color: 'var(--accent-foreground)' },
// Re-state the base state borders that this layer's plain `border-input` would otherwise beat.
'&:focus-visible': { borderColor: 'var(--ring)' },
'&[aria-invalid="true"]': { borderColor: 'var(--destructive)' },
}),
}
const sizes: Record<ToggleSize, CSSMixinDescriptor> = {
default: css({ height: '2.25rem', minWidth: '2.25rem', paddingInline: '0.5rem' }),
sm: css({ height: '2rem', minWidth: '2rem', paddingInline: '0.375rem' }),
lg: css({ height: '2.5rem', minWidth: '2.5rem', paddingInline: '0.625rem' }),
}
/**
* The `toggleVariants()` of shadcn/ui. Returns the toggle styles as a mix for any element; the
* element shows its "on" look through `data-state="on"`.
*/
export function toggleVariants({ variant = 'default', size = 'default' }: ToggleVariantOptions = {}) {
return [base, variants[variant], sizes[size]]
}
export function Toggle(handle: Handle<ToggleProps>) {
let uncontrolledPressed = handle.props.defaultPressed ?? false
return () => {
let {
variant = 'default',
size = 'default',
pressed: controlledPressed,
defaultPressed: _defaultPressed,
onPressedChange,
disabled,
mix,
...props
} = handle.props
let pressed = controlledPressed ?? uncontrolledPressed
return (
<button
type="button"
data-slot="toggle"
aria-pressed={pressed}
data-state={pressed ? 'on' : 'off'}
data-disabled={disabled ? '' : undefined}
disabled={disabled}
{...props}
mix={[
toggleVariants({ variant, size }),
mix,
on('click', (event) => {
if (event.defaultPrevented || handle.props.disabled) return
let next = !(handle.props.pressed ?? uncontrolledPressed)
if (handle.props.pressed === undefined) {
uncontrolledPressed = next
handle.update()
}
onPressedChange?.(next)
}),
]}
/>
)
}
}Update the import paths to match your project setup.
Usage
import { Toggle } from '#app/ui/toggle.tsx'<Toggle>Toggle</Toggle>Examples
Outline
Use variant="outline" for an outline style.
import { ItalicIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleOutline() {
return () => (
<Toggle variant="outline" aria-label="Toggle italic">import { ItalicIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleOutline() {
return () => (
<Toggle variant="outline" aria-label="Toggle italic">
<ItalicIcon />
</Toggle>
)
}Disabled
import { css } from 'remix/component'
import { UnderlineIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleDisabled() {import { css } from 'remix/component'
import { UnderlineIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleDisabled() {
return () => (
<Toggle aria-label="Toggle italic" disabled>
<UnderlineIcon mix={css({ height: '1rem', width: '1rem' })} />
</Toggle>
)
}Lg
import { ItalicIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleLg() {
return () => (
<Toggle size="lg" aria-label="Toggle italic">import { ItalicIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleLg() {
return () => (
<Toggle size="lg" aria-label="Toggle italic">
<ItalicIcon />
</Toggle>
)
}Sm
import { ItalicIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleSm() {
return () => (
<Toggle size="sm" aria-label="Toggle italic">import { ItalicIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleSm() {
return () => (
<Toggle size="sm" aria-label="Toggle italic">
<ItalicIcon />
</Toggle>
)
}With text
import { ItalicIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleWithText() {
return () => (
<Toggle aria-label="Toggle italic">import { ItalicIcon } from '#app/ui/icons.tsx'
import { Toggle } from '#app/ui/toggle.tsx'
export function ToggleWithText() {
return () => (
<Toggle aria-label="Toggle italic">
<ItalicIcon />
Italic
</Toggle>
)
}API Reference
Toggle
| Prop | Type | Default |
|---|---|---|
pressed | boolean | - |
defaultPressed | boolean | - |
onPressedChange | ((pressed: boolean) => void) | - |
variant | ToggleVariant | "default" |
size | ToggleSize | "default" |
mix | MixValue | - |