Accordion
accordionuiVertically stacked, collapsible panels — one or many open at a time. Use for FAQs, settings groups, and any place where space is tight but content needs to stay browsable. Hand-rolled runes state with WAI-ARIA keyboard support (arrows/Home/End), smooth animation, controlled or uncontrolled value.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/accordion.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/accordion.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/accordion.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/accordion.jsonManifest:
https://uipkge.dev/r/svelte/accordion.jsonInstalls to:src/lib/components/ui/accordion/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
variant | | default | optional |
type | 'single' | 'multiple' | single | optional |
valueControlled open value. Pair with `bind:value`. | string | string[] | undefined | — | optional |
defaultValueUncontrolled initial value. | string | string[] | — | optional |
collapsible | boolean | false | optional |
disabled | boolean | false | optional |
dir | 'ltr' | 'rtl' | — | optional |
orientation | 'horizontal' | 'vertical' | vertical | optional |
childRender your own root element with the accordion's props and styles. | Snippet<[{ props: Record<string, unknown> }]> | — | optional |
ref | HTMLDivElement | null | — | optional |
onValueChange | (value: string | string[] | undefined) => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
AccordionRootContextinterface AccordionRootContext {
readonly variant: AccordionVariant
readonly type: AccordionType
readonly collapsible: boolean
readonly disabled: boolean
readonly orientation: 'horizontal' | 'vertical'
isOpen: (value: string) => boolean
toggle: (value: string) => void
registerTrigger: (el: HTMLElement) => void
unregisterTrigger: (el: HTMLElement) => void
focusSiblingTrigger: (current: HTMLElement, target: 1 | -1 | 'first' | 'last') => void
}AccordionItemContextinterface AccordionItemContext {
readonly value: string
readonly triggerId: string
readonly contentId: string
readonly open: boolean
readonly disabled: boolean
}npm dependencies
Used by
Files installed (8)
src/lib/components/ui/accordion/Accordion.svelte4.5 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface AccordionProps extends HTMLAttributes<HTMLDivElement> { variant?: 'default' | 'separated' | 'ghost' type?: 'single' | 'multiple' /** Controlled open value. Pair with `bind:value`. */ value?: string | string[] | undefined /** Uncontrolled initial value. */ defaultValue?: string | string[] collapsible?: boolean disabled?: boolean dir?: 'ltr' | 'rtl' orientation?: 'horizontal' | 'vertical' /** Render your own root element with the accordion's props and styles. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLDivElement | null onValueChange?: (value: string | string[] | undefined) => void } </script> <script lang="ts"> import { setContext } from 'svelte' import { cn } from '$lib/utils' import { accordionVariants } from './accordion.variants' import { ACCORDION_ROOT_KEY, type AccordionRootContext } from './context' let { class: className, variant = 'default', type = 'single', value = $bindable(), defaultValue, collapsible = false, disabled = false, dir, orientation = 'vertical', child, children, ref = $bindable(null), onValueChange, ...restProps }: AccordionProps = $props() function normalizeSingle(v: string | string[] | undefined): string | undefined { return Array.isArray(v) ? v[0] : v } function normalizeMultiple(v: string | string[] | undefined): string[] { if (v === undefined) return [] return Array.isArray(v) ? [...v] : [v] } // Snapshot once: later `type` / `defaultValue` changes must not reset user state. const getInitialState = () => { const initial = value ?? defaultValue return { single: type === 'single' ? normalizeSingle(initial) : undefined, multiple: type === 'multiple' ? normalizeMultiple(initial) : [], } } const initialState = getInitialState() let internalSingle = $state<string | undefined>(initialState.single) let internalMultiple = $state<string[]>(initialState.multiple) // Controlled mode: a bound `value` always wins over internal state. $effect.pre(() => { if (value === undefined) return if (type === 'single') internalSingle = normalizeSingle(value) else internalMultiple = normalizeMultiple(value) }) function isOpen(itemValue: string): boolean { return type === 'single' ? internalSingle === itemValue : internalMultiple.includes(itemValue) } function toggle(itemValue: string) { if (type === 'single') { const next = internalSingle === itemValue ? (collapsible ? undefined : itemValue) : itemValue internalSingle = next value = next onValueChange?.(next) } else { const next = internalMultiple.includes(itemValue) ? internalMultiple.filter((v) => v !== itemValue) : [...internalMultiple, itemValue] internalMultiple = next value = next onValueChange?.(next) } } // Trigger registry for WAI-ARIA arrow-key navigation. Registration order // follows mount order, which matches DOM order for static item lists. const triggers: HTMLElement[] = [] function registerTrigger(el: HTMLElement) { if (!triggers.includes(el)) triggers.push(el) } function unregisterTrigger(el: HTMLElement) { const i = triggers.indexOf(el) if (i >= 0) triggers.splice(i, 1) } function focusSiblingTrigger(current: HTMLElement, target: 1 | -1 | 'first' | 'last') { if (triggers.length === 0) return const i = triggers.indexOf(current) if (target === 'first') triggers[0]?.focus() else if (target === 'last') triggers[triggers.length - 1]?.focus() else triggers[(i + target + triggers.length) % triggers.length]?.focus() } setContext<AccordionRootContext>(ACCORDION_ROOT_KEY, { get variant() { return variant }, get type() { return type }, get collapsible() { return collapsible }, get disabled() { return disabled }, get orientation() { return orientation }, isOpen, toggle, registerTrigger, unregisterTrigger, focusSiblingTrigger, }) const mergedProps = $derived({ 'data-uipkge': '', 'data-slot': 'accordion', 'data-variant': variant, 'data-orientation': orientation, dir, class: cn(accordionVariants({ variant }), className), ...restProps, }) </script> {#if child} {@render child({ props: mergedProps })} {:else} <div bind:this={ref} {...mergedProps}>{@render children?.()}</div> {/if}src/lib/components/ui/accordion/AccordionContent.svelte1.3 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getAccordionItem, getAccordionRoot } from './context' let { class: className, children, ref = $bindable(null), ...restProps }: AccordionContentProps = $props() const root = getAccordionRoot() const item = getAccordionItem() const open = $derived(item?.open ?? true) const orientation = $derived(root?.orientation ?? 'vertical') </script> <div bind:this={ref} role="region" id={item?.contentId} aria-labelledby={item?.triggerId} data-uipkge="" data-slot="accordion-content" data-state={open ? 'open' : 'closed'} data-orientation={orientation} hidden={!open} class={cn( 'text-muted-foreground overflow-hidden text-sm', // Height via CSS vars + tw-animate-css (not height:auto). // duration/ease set --tw-duration/--tw-ease consumed by the utility. 'data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down', 'duration-200 ease-[cubic-bezier(0.32,0.72,0,1)]', 'motion-reduce:animate-none', className, )} {...restProps} > <div class="pt-0 pb-4"> {@render children?.()} </div> </div>src/lib/components/ui/accordion/AccordionHeader.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface AccordionHeaderProps extends HTMLAttributes<HTMLElement> { ref?: HTMLElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: AccordionHeaderProps = $props() </script> <h3 bind:this={ref} data-uipkge="" data-slot="accordion-header" class={cn('flex', className)} {...restProps}> {@render children?.()} </h3>src/lib/components/ui/accordion/AccordionItem.svelte1.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> { value: string disabled?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { setContext } from 'svelte' import { cn } from '$lib/utils' import { accordionItemVariants } from './accordion.variants' import { ACCORDION_ITEM_KEY, getAccordionRoot, type AccordionItemContext } from './context' let { value, disabled = false, class: className, children, ref = $bindable(null), ...restProps }: AccordionItemProps = $props() const root = getAccordionRoot() const open = $derived(root?.isOpen(value) ?? true) const variant = $derived(root?.variant ?? 'default') const orientation = $derived(root?.orientation ?? 'vertical') const isDisabled = $derived(disabled || (root?.disabled ?? false)) const itemId = $props.id() const triggerId = `${itemId}-trigger` const contentId = `${itemId}-content` setContext<AccordionItemContext>(ACCORDION_ITEM_KEY, { get value() { return value }, get triggerId() { return triggerId }, get contentId() { return contentId }, get open() { return open }, get disabled() { return isDisabled }, }) </script> <div bind:this={ref} data-uipkge="" data-slot="accordion-item" data-state={open ? 'open' : 'closed'} data-disabled={isDisabled ? '' : undefined} data-orientation={orientation} class={cn(accordionItemVariants({ variant }), className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/accordion/AccordionTrigger.svelte3.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface AccordionTriggerProps extends HTMLButtonAttributes { /** Render your own trigger element with the trigger's props and styles. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLButtonElement | null } </script> <script lang="ts"> import type { KeyboardEventHandler, MouseEventHandler } from 'svelte/elements' import { ChevronDown } from '@lucide/svelte' import { cn } from '$lib/utils' import { accordionTriggerVariants } from './accordion.variants' import { getAccordionItem, getAccordionRoot } from './context' let { class: className, child, children, ref = $bindable(null), disabled = false, onclick, onkeydown, ...restProps }: AccordionTriggerProps = $props() const root = getAccordionRoot() const item = getAccordionItem() const open = $derived(item?.open ?? true) const variant = $derived(root?.variant ?? 'default') const orientation = $derived(root?.orientation ?? 'vertical') const isDisabled = $derived(disabled || (item?.disabled ?? false)) $effect(() => { const el = ref if (!el) return root?.registerTrigger(el) return () => root?.unregisterTrigger(el) }) const handleClick: MouseEventHandler<HTMLButtonElement> = (event) => { onclick?.(event) if (event.defaultPrevented || isDisabled || !item) return root?.toggle(item.value) } const handleKeydown: KeyboardEventHandler<HTMLButtonElement> = (event) => { onkeydown?.(event) if (event.defaultPrevented || !ref) return const vertical = orientation !== 'horizontal' let target: 1 | -1 | 'first' | 'last' | null = null if (event.key === 'Home') target = 'first' else if (event.key === 'End') target = 'last' else if (vertical && event.key === 'ArrowDown') target = 1 else if (vertical && event.key === 'ArrowUp') target = -1 else if (!vertical && event.key === 'ArrowRight') target = 1 else if (!vertical && event.key === 'ArrowLeft') target = -1 if (target !== null) { event.preventDefault() root?.focusSiblingTrigger(ref, target) } } const mergedProps = $derived({ 'data-uipkge': '', 'data-slot': 'accordion-trigger', 'data-state': open ? ('open' as const) : ('closed' as const), 'data-disabled': isDisabled ? '' : undefined, 'data-orientation': orientation, id: item?.triggerId, 'aria-expanded': open, 'aria-controls': item?.contentId, disabled: isDisabled, type: 'button' as const, class: cn(accordionTriggerVariants({ variant }), className), onclick: handleClick, onkeydown: handleKeydown, ...restProps, }) </script> {#if child} {@render child({ props: mergedProps })} {:else} <button bind:this={ref} {...mergedProps}> {@render children?.()} <ChevronDown class="text-muted-foreground size-4 shrink-0 transition-transform duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[state=open]/accordion-trigger:rotate-180 motion-reduce:transition-none" aria-hidden="true" /> </button> {/if}src/lib/components/ui/accordion/accordion.variants.ts1.8 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so consuming Vue SFCs can import without creating a circular * dependency through the index. See card.variants.ts for the canonical * example + the SSR symptom that motivated the split. */ export const accordionVariants = cva('w-full', { variants: { variant: { // Bottom-border between items (the classic shadcn look). default: '', // Each item is its own bordered card with a small gap between them. separated: 'space-y-2', // Borderless. Use when the parent container already provides framing. ghost: '', }, }, defaultVariants: { variant: 'default', }, }) export const accordionItemVariants = cva('', { variants: { variant: { default: 'border-b border-border last:border-b-0', separated: 'rounded-md border border-border bg-card overflow-hidden', ghost: '', }, }, defaultVariants: { variant: 'default', }, }) export const accordionTriggerVariants = cva( 'group/accordion-trigger flex w-full items-center justify-between gap-3 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/40 hover:text-primary disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { default: 'py-4', separated: 'px-4 py-3 hover:bg-muted/50', ghost: 'py-3', }, }, defaultVariants: { variant: 'default', }, }, ) export type AccordionVariants = VariantProps<typeof accordionVariants> export type AccordionItemVariants = VariantProps<typeof accordionItemVariants> export type AccordionTriggerVariants = VariantProps<typeof accordionTriggerVariants>src/lib/components/ui/accordion/context.ts1.6 kB
import { getContext, hasContext } from 'svelte' export type AccordionVariant = 'default' | 'separated' | 'ghost' export type AccordionType = 'single' | 'multiple' /** * Root state shared with items via `setContext` / `getContext` (Svelte * counterpart of Vue's provide/inject). Implemented with plain getters that * close over the root component's `$state`, so reads inside a child's * `$derived` stay reactive without a `.svelte.ts` module. */ export interface AccordionRootContext { readonly variant: AccordionVariant readonly type: AccordionType readonly collapsible: boolean readonly disabled: boolean readonly orientation: 'horizontal' | 'vertical' isOpen: (value: string) => boolean toggle: (value: string) => void registerTrigger: (el: HTMLElement) => void unregisterTrigger: (el: HTMLElement) => void focusSiblingTrigger: (current: HTMLElement, target: 1 | -1 | 'first' | 'last') => void } export interface AccordionItemContext { readonly value: string readonly triggerId: string readonly contentId: string readonly open: boolean readonly disabled: boolean } export const ACCORDION_ROOT_KEY = Symbol('uipkge-accordion-root') export const ACCORDION_ITEM_KEY = Symbol('uipkge-accordion-item') export function getAccordionRoot(): AccordionRootContext | undefined { return hasContext(ACCORDION_ROOT_KEY) ? getContext<AccordionRootContext>(ACCORDION_ROOT_KEY) : undefined } export function getAccordionItem(): AccordionItemContext | undefined { return hasContext(ACCORDION_ITEM_KEY) ? getContext<AccordionItemContext>(ACCORDION_ITEM_KEY) : undefined }src/lib/components/ui/accordion/index.ts0.8 kB
export { default as Accordion, type AccordionProps } from './Accordion.svelte' export { default as AccordionContent, type AccordionContentProps } from './AccordionContent.svelte' export { default as AccordionHeader, type AccordionHeaderProps } from './AccordionHeader.svelte' export { default as AccordionItem, type AccordionItemProps } from './AccordionItem.svelte' export { default as AccordionTrigger, type AccordionTriggerProps } from './AccordionTrigger.svelte' // Re-export variant API from the sibling file (kept separate to avoid the // Component.svelte <-> index.ts circular import that broke dev SSR for Card). export { accordionVariants, accordionItemVariants, accordionTriggerVariants, type AccordionVariants, type AccordionItemVariants, type AccordionTriggerVariants, } from './accordion.variants'
Raw manifest:https://uipkge.dev/r/svelte/accordion.json