Menu

Change language

GitHub repo

Accordion

accordionui

Vertically 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

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/accordion.json
Manifest:https://uipkge.dev/r/svelte/accordion.jsonInstalls to:src/lib/components/ui/accordion/

Examples

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
variantdefaultoptional
type'single' | 'multiple'singleoptional
value

Controlled open value. Pair with `bind:value`.

string | string[] | undefined—optional
defaultValue

Uncontrolled initial value.

string | string[]—optional
collapsiblebooleanfalseoptional
disabledbooleanfalseoptional
dir'ltr' | 'rtl'—optional
orientation'horizontal' | 'vertical'verticaloptional
child

Render your own root element with the accordion's props and styles.

Snippet<[{ props: Record<string, unknown> }]>—optional
refHTMLDivElement | 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.

AccordionRootContext
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
}
AccordionItemContext
interface AccordionItemContext {
  readonly value: string
  readonly triggerId: string
  readonly contentId: string
  readonly open: boolean
  readonly disabled: boolean
}

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
  • src/lib/components/ui/accordion/AccordionHeader.svelte0.5 kB
  • src/lib/components/ui/accordion/AccordionItem.svelte1.6 kB
  • src/lib/components/ui/accordion/AccordionTrigger.svelte3.1 kB
  • src/lib/components/ui/accordion/accordion.variants.ts1.8 kB
  • src/lib/components/ui/accordion/context.ts1.6 kB
  • src/lib/components/ui/accordion/index.ts0.8 kB

Raw manifest:https://uipkge.dev/r/svelte/accordion.json