liquefy-ui
uiliquid-accordionsource: ok · refreshed
LiquidAccordion
Stacked disclosure panels with real headings and a height-measured open animation.
npx shadcn add "https://liquefy-ui.com/r/liquid-accordion.json"free
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react@1.8.0
Registry dependencies
https://liquefy-ui.com/r/liquefy-ui.jsonhttps://liquefy-ui.com/r/internal-glyphs.jsonhttps://liquefy-ui.com/r/liquid-surface.jsonhttps://liquefy-ui.com/r/styles-prop.json
Files1 · source cached 10/8/2026, 6:17:01 AM
registry/liquid-accordion.tsx
'use client'
import { Accordion } from '@base-ui/react/accordion'
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { ChevronDownGlyph } from '@/lib/internal-glyphs'
import { LiquidSurface, type LiquidSurfaceProps } from '@/components/ui/liquid-surface'
import { useLiquidStyles, type LiquidStyleProps } from '@/lib/styles-prop'
// Base UI owns the roving focus across headers, the aria-controls wiring and the
// panel height measurement (exposed as --accordion-panel-height). liquefy-ui keeps
// the glass surface, the chevron and the jelly open animation.
export type LiquidAccordionProps = Omit<LiquidSurfaceProps, 'children' | 'defaultValue'> & {
children: ReactNode
defaultValue?: string[]
multiple?: boolean
onValueChange?: (value: string[]) => void
value?: string[]
}
export const LiquidAccordion = forwardRef<HTMLDivElement, LiquidAccordionProps>(({
children,
className,
defaultValue = [],
multiple = false,
onValueChange,
value,
...props
}, ref) => (
<Accordion.Root
defaultValue={defaultValue}
multiple={multiple}
onValueChange={(next) => onValueChange?.(next.map(String))}
render={(
<LiquidSurface
className={['lq-accordion', className].filter(Boolean).join(' ')}
interactive={false}
ref={ref}
{...props}
/>
)}
value={value}
>
{children}
</Accordion.Root>
))
LiquidAccordion.displayName = 'LiquidAccordion'
// `title` is omitted because HTMLAttributes declares it as the tooltip attribute,
// i.e. a `string`, and intersecting that with `ReactNode` leaves `string` — the
// header below could never be given an element.
export type LiquidAccordionItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & LiquidStyleProps & {
children: ReactNode
subtitle?: ReactNode
title: ReactNode
value: string
}
export const LiquidAccordionItem = forwardRef<HTMLDivElement, LiquidAccordionItemProps>(({
children,
className,
style,
styles,
subtitle,
title,
value,
...props
}, ref) => {
const root = useLiquidStyles('lq-accordion__item', { className, style, styles })
return (
<Accordion.Item className={root.className} ref={ref} style={root.style} value={value} {...props}>
{/* A real heading, so screen-reader users can jump between sections. */}
<Accordion.Header className="lq-accordion__header">
<Accordion.Trigger className="lq-accordion__trigger">
<span className="lq-accordion__heading">
<span className="lq-accordion__title">{title}</span>
{subtitle && <span className="lq-accordion__subtitle">{subtitle}</span>}
</span>
<span className="lq-accordion__chevron"><ChevronDownGlyph size={15} /></span>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="lq-accordion__content">
<div className="lq-accordion__content-inner">{children}</div>
</Accordion.Panel>
</Accordion.Item>
)
})
LiquidAccordionItem.displayName = 'LiquidAccordionItem'