Awesome Cursor Rules
uiaccordionsource: ok · refreshed
Accordion
Collapsible accordion for Tile UI.
npx shadcn add "https://react.tileui.zmorg.cn/r/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 · lightRegistry dependencies
@tile-ui/core@tile-ui/styles
Files3 · source cached 10/2/2026, 12:45:52 AM
packages/react/src/components/accordion/accordion.tsx
import React, { createContext, useContext, useId, useState } from 'react';
import { accordionStyleKeys, getAccordionState, getAccordionNextValues } from '../lib/core';
import type { AccordionBaseProps, AccordionItemBaseProps } from '../lib/core';
import styles from './accordion.module.scss';
interface AccordionContextValue {
type: 'single' | 'multiple';
collapsible: boolean;
value: string | string[];
toggleItem: (value: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordionContext(): AccordionContextValue {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion sub-components must be used within <Accordion>.');
}
return context;
}
interface AccordionItemContextValue {
value: string;
open: boolean;
disabled: boolean;
contentId: string;
}
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
function useAccordionItemContext(): AccordionItemContextValue {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error('AccordionItem sub-components must be used within <AccordionItem>.');
}
return context;
}
export interface AccordionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'value' | 'defaultValue'>, AccordionBaseProps {}
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
({ className = '', type = 'single', collapsible = false, value, defaultValue, onValueChange, children, ...props }, ref) => {
const [internalValue, setInternalValue] = useState<string | string[]>(() => {
if (type === 'multiple') {
return Array.isArray(defaultValue) ? defaultValue : [];
}
return typeof defaultValue === 'string' ? defaultValue : '';
});
const currentValue = value !== undefined ? value : internalValue;
const normalized = type === 'multiple' ? (Array.isArray(currentValue) ? currentValue : []) : typeof currentValue === 'string' ? currentValue : '';
function toggleItem(itemValue: string) {
let next: string | string[];
if (type === 'multiple') {
const list = Array.isArray(normalized) ? normalized : [];
next = getAccordionNextValues(itemValue, list);
} else if (normalized === itemValue) {
next = collapsible ? '' : normalized;
} else {
next = itemValue;
}
if (value === undefined) {
setInternalValue(next);
}
onValueChange?.(next);
}
return (
<AccordionContext.Provider value={{ type, collapsible, value: normalized, toggleItem }}>
<div ref={ref} data-slot="accordion" className={`${styles[accordionStyleKeys.root]} ${className}`} {...props}>
{children}
</div>
</AccordionContext.Provider>
);
},
);
Accordion.displayName = 'Accordion';
export interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement>, AccordionItemBaseProps {
value: string;
}
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(({ className = '', value, disabled = false, children, ...props }, ref) => {
const accordion = useAccordionContext();
const open = accordion.type === 'multiple' ? Array.isArray(accordion.value) && accordion.value.includes(value) : accordion.value === value;
const state = getAccordionState(open);
const contentId = useId();
return (
<AccordionItemContext.Provider value={{ value, open, disabled, contentId }}>
<div ref={ref} data-state={state} className={`${styles[accordionStyleKeys.item]} ${className}`} {...props}>
{children}
</div>
</AccordionItemContext.Provider>
);
});
AccordionItem.displayName = 'AccordionItem';
export interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(({ className = '', children, onClick, ...props }, ref) => {
const accordion = useAccordionContext();
const item = useAccordionItemContext();
const state = getAccordionState(item.open);
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
if (item.disabled) {
return;
}
accordion.toggleItem(item.value);
onClick?.(event);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
if (!keys.includes(event.key)) {
return;
}
const root = event.currentTarget.closest('[data-slot="accordion"]');
if (!root) {
return;
}
const triggers = Array.from(root.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')).filter((button) => !button.disabled);
if (triggers.length === 0) {
return;
}
const index = triggers.indexOf(event.currentTarget);
let next = index;
if (event.key === 'ArrowDown') {
next = (index + 1) % triggers.length;
} else if (event.key === 'ArrowUp') {
next = (index - 1 + triggers.length) % triggers.length;
} else if (event.key === 'Home') {
next = 0;
} else if (event.key === 'End') {
next = triggers.length - 1;
}
event.preventDefault();
triggers[next]?.focus();
}
return (
<div className={styles[accordionStyleKeys.header]}>
<button
ref={ref}
type="button"
aria-expanded={item.open}
aria-controls={item.contentId}
data-state={state}
disabled={item.disabled}
className={`${styles[accordionStyleKeys.trigger]} ${className}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
{...props}>
{children}
<svg
className={styles[accordionStyleKeys.chevron]}
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
);
});
AccordionTrigger.displayName = 'AccordionTrigger';
export interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(({ className = '', children, ...props }, ref) => {
const item = useAccordionItemContext();
const state = getAccordionState(item.open);
return (
<div ref={ref} id={item.contentId} data-state={state} className={`${styles[accordionStyleKeys.content]} ${className}`} {...props}>
<div className={styles[accordionStyleKeys.contentInner]}>{children}</div>
</div>
);
});
AccordionContent.displayName = 'AccordionContent';
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
export default Accordion;
packages/styles/scss/components/accordion.module.scss
@use '../../../styles/variables/colors' as *;
.root {
width: 100%;
}
.item {
border-bottom: 1px solid $border;
&:last-child {
border-bottom: none;
}
}
.header {
display: flex;
}
.trigger {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: $spacing-4;
border-radius: $radius-md;
padding: $spacing-4 0;
text-align: left;
font-size: $text-sm;
font-weight: $font-medium;
transition: all $transition-fast;
outline: none;
&:hover {
text-decoration: underline;
}
&:focus-visible {
box-shadow:
0 0 0 2px $background,
0 0 0 4px $ring;
}
&:disabled {
pointer-events: none;
opacity: 0.5;
}
&[data-state='open'] .chevron {
transform: rotate(180deg);
}
}
.chevron {
pointer-events: none;
width: 1rem;
height: 1rem;
flex-shrink: 0;
color: $muted-foreground;
transition: transform $transition-normal;
}
.content {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
padding-bottom: 0;
text-align: left;
font-size: $text-sm;
transition:
grid-template-rows $transition-slow,
padding-bottom $transition-slow;
&[data-state='open'] {
grid-template-rows: 1fr;
padding-bottom: $spacing-4;
}
}
.contentInner {
overflow: hidden;
min-height: 0;
}
packages/react/src/components/accordion/index.ts
export {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
type AccordionProps,
type AccordionItemProps,
type AccordionTriggerProps,
type AccordionContentProps,
} from './accordion';