shadimage
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 · light

Registry 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';

More from Awesome Cursor Rules

Browse all components in this library →