shadimage
ai2
componentaccordion-styledsource: ok · hit

Accordion essentials

Styled accordion: the essentials set. 5 decorative accordion variations (BorderedAccordion, SeparatedAccordion, FilledAccordion, GhostAccordion, PlusAccordion) on the ai2 token system, powered by framer-motion, reduced-motion aware and sized sm to xl. Part of the free styled layer.

npx shadcn add "https://ai2.design/r/accordion-styled.json"
free

Interaction

accordion

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
motion@^12.42.2lucide-react@^1.23.0

Registry dependencies

@ai2/tokens

Files1 · source cached 10/8/2026, 12:53:54 PM

registry/ai2/styled/accordion-styled.tsx
"use client"

import * as React from "react"
import { ChevronDown, Plus } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"

/* Accordion family: 5 decorative accordions. Every export is a complete
   accordion: internal single-open state, a button header with aria-expanded,
   framer height auto animation. Colour comes ONLY from tokens. Alpha via
   color-mix. Under reduced-motion opening and closing are instant. Size =
   padding + text scale. Renders without props too. */

export type StyledSize = "sm" | "md" | "lg" | "xl"

export interface StyledAccordionItem {
  value: string
  title: React.ReactNode
  content: React.ReactNode
}

interface StyledAccordionProps {
  className?: string
  size?: StyledSize
  items?: StyledAccordionItem[]
  defaultOpen?: string
}

const headerPad: Record<StyledSize, string> = {
  sm: "px-3 py-2.5 text-sm",
  md: "px-4 py-3 text-sm",
  lg: "px-5 py-4 text-base",
  xl: "px-6 py-5 text-lg",
}

const bodyPad: Record<StyledSize, string> = {
  sm: "px-3 pb-2.5 text-sm",
  md: "px-4 pb-3 text-sm",
  lg: "px-5 pb-4 text-base",
  xl: "px-6 pb-5 text-base",
}

const iconSize: Record<StyledSize, string> = {
  sm: "size-4",
  md: "size-4",
  lg: "size-5",
  xl: "size-5",
}

const hoverTint =
  "hover:bg-[color-mix(in_oklab,var(--color-foreground)_5%,transparent)]"

const defaultItems: StyledAccordionItem[] = [
  {
    value: "item-1",
    title: "What is included?",
    content:
      "Every plan ships the full component library, semantic tokens, and lifetime updates for the tier you own.",
  },
  {
    value: "item-2",
    title: "How does installation work?",
    content:
      "Add any component with a single CLI command. Files land in your project and stay fully editable.",
  },
  {
    value: "item-3",
    title: "Can I use it in production?",
    content:
      "Yes. The system is framework agnostic, self contained, and safe to ship to any number of projects.",
  },
]

function useSingleOpen(defaultOpen?: string) {
  const [open, setOpen] = React.useState<string | null>(defaultOpen ?? null)
  const toggle = React.useCallback(
    (value: string) => setOpen((cur) => (cur === value ? null : value)),
    []
  )
  return { open, toggle }
}

const headerBase =
  "flex w-full items-center justify-between gap-3 text-left font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50"

function Panel({
  isOpen,
  size,
  children,
}: {
  isOpen: boolean
  size: StyledSize
  children: React.ReactNode
}) {
  const reduce = useReducedMotion()
  return (
    <AnimatePresence initial={false}>
      {isOpen && (
        <motion.div
          key="panel"
          initial={reduce ? false : { height: 0, opacity: 0 }}
          animate={{ height: "auto", opacity: 1 }}
          exit={reduce ? { height: 0, opacity: 0 } : { height: 0, opacity: 0 }}
          transition={
            reduce ? { duration: 0 } : { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
          }
          className="overflow-hidden"
        >
          <div className={cn(bodyPad[size], "text-muted-foreground")}>
            {children}
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  )
}

function Chevron({ isOpen, size }: { isOpen: boolean; size: StyledSize }) {
  const reduce = useReducedMotion()
  return (
    <motion.span
      aria-hidden
      className="shrink-0 text-muted-foreground"
      animate={{ rotate: isOpen ? 180 : 0 }}
      transition={reduce ? { duration: 0 } : { duration: 0.2 }}
    >
      <ChevronDown className={iconSize[size]} />
    </motion.span>
  )
}

function PlusMark({ isOpen, size }: { isOpen: boolean; size: StyledSize }) {
  const reduce = useReducedMotion()
  return (
    <motion.span
      aria-hidden
      className="shrink-0 text-muted-foreground"
      animate={{ rotate: isOpen ? 45 : 0 }}
      transition={reduce ? { duration: 0 } : { duration: 0.2 }}
    >
      <Plus className={iconSize[size]} />
    </motion.span>
  )
}

/* Bordered: tek cerceveli kap, satirlar token cizgiyle bolunur, chevron doner. */
export function BorderedAccordion({
  className,
  size = "md",
  items = defaultItems,
  defaultOpen,
}: StyledAccordionProps) {
  const { open, toggle } = useSingleOpen(defaultOpen)
  return (
    <div
      data-slot="styled-accordion"
      className={cn(
        "overflow-hidden rounded-xl border border-border bg-background",
        className
      )}
    >
      {items.map((item, i) => {
        const isOpen = open === item.value
        return (
          <div key={item.value} className={cn(i > 0 && "border-t border-border")}>
            <button
              type="button"
              aria-expanded={isOpen}
              onClick={() => toggle(item.value)}
              className={cn(headerBase, headerPad[size], hoverTint)}
            >
              {item.title}
              <Chevron isOpen={isOpen} size={size} />
            </button>
            <Panel isOpen={isOpen} size={size}>
              {item.content}
            </Panel>
          </div>
        )
      })}
    </div>
  )
}

/* Separated: her item ayri yuvarlak token kart, aralarinda bosluk. */
export function SeparatedAccordion({
  className,
  size = "md",
  items = defaultItems,
  defaultOpen,
}: StyledAccordionProps) {
  const { open, toggle } = useSingleOpen(defaultOpen)
  return (
    <div
      data-slot="styled-accordion"
      className={cn("flex flex-col gap-2", className)}
    >
      {items.map((item) => {
        const isOpen = open === item.value
        return (
          <div
            key={item.value}
            className="overflow-hidden rounded-xl border border-border bg-background"
          >
            <button
              type="button"
              aria-expanded={isOpen}
              onClick={() => toggle(item.value)}
              className={cn(headerBase, headerPad[size], hoverTint)}
            >
              {item.title}
              <Chevron isOpen={isOpen} size={size} />
            </button>
            <Panel isOpen={isOpen} size={size}>
              {item.content}
            </Panel>
          </div>
        )
      })}
    </div>
  )
}

/* Filled: acik olan item yumusak token dolgu arka plani alir. */
export function FilledAccordion({
  className,
  size = "md",
  items = defaultItems,
  defaultOpen,
}: StyledAccordionProps) {
  const { open, toggle } = useSingleOpen(defaultOpen)
  return (
    <div
      data-slot="styled-accordion"
      className={cn("flex flex-col gap-1", className)}
    >
      {items.map((item) => {
        const isOpen = open === item.value
        return (
          <div
            key={item.value}
            className={cn(
              "overflow-hidden rounded-lg transition-colors",
              isOpen && "bg-surface-2"
            )}
          >
            <button
              type="button"
              aria-expanded={isOpen}
              onClick={() => toggle(item.value)}
              className={cn(
                headerBase,
                headerPad[size],
                !isOpen && hoverTint
              )}
            >
              {item.title}
              <Chevron isOpen={isOpen} size={size} />
            </button>
            <Panel isOpen={isOpen} size={size}>
              {item.content}
            </Panel>
          </div>
        )
      })}
    </div>
  )
}

/* Ghost: no frame, minimal; only a chevron + a hover token tint. */
export function GhostAccordion({
  className,
  size = "md",
  items = defaultItems,
  defaultOpen,
}: StyledAccordionProps) {
  const { open, toggle } = useSingleOpen(defaultOpen)
  return (
    <div
      data-slot="styled-accordion"
      className={cn("flex flex-col", className)}
    >
      {items.map((item) => {
        const isOpen = open === item.value
        return (
          <div key={item.value}>
            <button
              type="button"
              aria-expanded={isOpen}
              onClick={() => toggle(item.value)}
              className={cn(
                headerBase,
                headerPad[size],
                "rounded-lg",
                hoverTint
              )}
            >
              {item.title}
              <Chevron isOpen={isOpen} size={size} />
            </button>
            <Panel isOpen={isOpen} size={size}>
              {item.content}
            </Panel>
          </div>
        )
      })}
    </div>
  )
}

/* Plus: token arti ikonu, acikken doner ve x'e donusur. */
export function PlusAccordion({
  className,
  size = "md",
  items = defaultItems,
  defaultOpen,
}: StyledAccordionProps) {
  const { open, toggle } = useSingleOpen(defaultOpen)
  return (
    <div
      data-slot="styled-accordion"
      className={cn("flex flex-col", className)}
    >
      {items.map((item, i) => {
        const isOpen = open === item.value
        return (
          <div key={item.value} className={cn(i > 0 && "border-t border-border")}>
            <button
              type="button"
              aria-expanded={isOpen}
              onClick={() => toggle(item.value)}
              className={cn(headerBase, headerPad[size])}
            >
              {item.title}
              <PlusMark isOpen={isOpen} size={size} />
            </button>
            <Panel isOpen={isOpen} size={size}>
              {item.content}
            </Panel>
          </div>
        )
      })}
    </div>
  )
}

More from ai2

Browse all components in this library →