Accordion
Organizes content into collapsible sections, allowing users to focus on one or more sections at a time.
<script lang="ts">
import { Accordion } from "bits-ui";
import CaretDown from "phosphor-svelte/lib/CaretDown";
const items = [
{
value: "1",
title: "What is the meaning of life?",
content:
"To become a better person, to help others, and to leave the world a better place than you found it."
},
{
value: "2",
title: "How do I become a better person?",
content:
"Read books, listen to podcasts, and surround yourself with people who inspire you."
},
{
value: "3",
title: "What is the best way to help others?",
content: "Give them your time, attention, and love."
}
];
</script>
<Accordion.Root class="w-full sm:max-w-[70%]" type="multiple">
{#each items as item (item.value)}
<Accordion.Item
value={item.value}
class="group border-b border-dark-10 px-1.5"
>
<Accordion.Header>
<Accordion.Trigger
class="flex w-full flex-1 select-none items-center justify-between py-5 text-[15px] font-medium transition-all [&[data-state=open]>span>svg]:rotate-180"
>
<span class="w-full text-left">
{item.title}
</span>
<span
class="inline-flex size-8 items-center justify-center rounded-[7px] bg-transparent transition-all hover:bg-dark-10"
>
<CaretDown class="size-[18px] transition-all duration-200" />
</span>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content
class="overflow-hidden text-sm tracking-[-0.01em] data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div class="pb-[25px]">
{item.content}
</div>
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
import typography from "@tailwindcss/typography";
import animate from "tailwindcss-animate";
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
container: {
center: true,
screens: {
"2xl": "1440px",
},
},
extend: {
colors: {
border: {
DEFAULT: "hsl(var(--border-card))",
input: "hsl(var(--border-input))",
"input-hover": "hsl(var(--border-input-hover))",
},
background: {
DEFAULT: "hsl(var(--background) / <alpha-value>)",
alt: "hsl(var(--background-alt) / <alpha-value>)",
},
foreground: {
DEFAULT: "hsl(var(--foreground) / <alpha-value>)",
alt: "hsl(var(--foreground-alt) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground))",
},
dark: {
DEFAULT: "hsl(var(--dark) / <alpha-value>)",
4: "hsl(var(--dark-04))",
10: "hsl(var(--dark-10))",
40: "hsl(var(--dark-40))",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
},
contrast: {
DEFAULT: "hsl(var(--contrast) / <alpha-value>)",
},
},
fontFamily: {
sans: ["Inter", ...fontFamily.sans],
mono: ["Source Code Pro", ...fontFamily.mono],
alt: ["Courier", ...fontFamily.sans],
},
fontSize: {
xxs: "10px",
},
borderWidth: {
6: "6px",
},
borderRadius: {
card: "16px",
"card-lg": "20px",
"card-sm": "10px",
input: "9px",
button: "5px",
"5px": "5px",
"9px": "9px",
"10px": "10px",
"15px": "15px",
},
height: {
input: "3rem",
"input-sm": "2.5rem",
},
boxShadow: {
mini: "var(--shadow-mini)",
"mini-inset": "var(--shadow-mini-inset)",
popover: "var(--shadow-popover)",
kbd: "var(--shadow-kbd)",
btn: "var(--shadow-btn)",
card: "var(--shadow-card)",
"date-field-focus": "var(--shadow-date-field-focus)",
},
opacity: {
8: "0.08",
},
scale: {
80: ".80",
98: ".98",
99: ".99",
},
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--bits-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--bits-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
plugins: [typography, animate],
};
@import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Colors */
--background: 0 0% 100%;
--background-alt: 0 0% 100%;
--foreground: 0 0% 9%;
--foreground-alt: 0 0% 32%;
--muted: 240 5% 96%;
--muted-foreground: 0 0% 9% / 0.4;
--border: 240 6% 10%;
--border-input: 240 6% 10% / 0.17;
--border-input-hover: 240 6% 10% / 0.4;
--border-card: 240 6% 10% / 0.1;
--dark: 240 6% 10%;
--dark-10: 240 6% 10% / 0.1;
--dark-40: 240 6% 10% / 0.4;
--dark-04: 240 6% 10% / 0.04;
--accent: 204 94% 94%;
--accent-foreground: 204 80% 16%;
--destructive: 347 77% 50%;
/* black */
--constrast: 0 0% 0%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.04) inset;
--shadow-popover: 0px 7px 12px 3px hsla(var(--dark-10));
--shadow-kbd: 0px 2px 0px 0px rgba(0, 0, 0, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.03);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(24, 24, 27, 0.17);
}
.dark {
/* Colors */
--background: 0 0% 5%;
--background-alt: 0 0% 8%;
--foreground: 0 0% 95%;
--foreground-alt: 0 0% 70%;
--muted: 240 4% 16%;
--muted-foreground: 0 0% 100% / 0.4;
--border: 0 0% 96%;
--border-input: 0 0% 96% / 0.17;
--border-input-hover: 0 0% 96% / 0.4;
--border-card: 0 0% 96% / 0.1;
--dark: 0 0% 96%;
--dark-40: 0 0% 96% / 0.4;
--dark-10: 0 0% 96% / 0.1;
--dark-04: 0 0% 96% / 0.04;
--accent: 204 90 90%;
--accent-foreground: 204 94% 94%;
--destructive: 350 89% 60%;
/* white */
--constrast: 0 0% 100%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.3);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.5) inset;
--shadow-popover: 0px 7px 12px 3px hsla(0deg 0% 0% / 30%);
--shadow-kbd: 0px 2px 0px 0px rgba(255, 255, 255, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.2);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.4);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(244, 244, 245, 0.1);
}
}
@layer base {
* {
@apply border-border;
}
html {
-webkit-text-size-adjust: 100%;
font-variation-settings: normal;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
/* Mobile tap highlight */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color */
html {
-webkit-tap-highlight-color: rgba(128, 128, 128, 0.5);
}
::selection {
background: #fdffa4;
color: black;
}
/* === Scrollbars === */
::-webkit-scrollbar {
@apply w-2;
@apply h-2;
}
::-webkit-scrollbar-track {
@apply !bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply rounded-card-lg !bg-dark-10;
}
::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0);
}
/* Firefox */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color#browser_compatibility */
html {
scrollbar-color: var(--bg-muted);
}
.antialised {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full border-4 border-background bg-muted text-center -indent-px font-mono text-base font-medium;
@apply ml-[-50px] mt-[-4px];
content: counter(step);
}
}
@layer components {
*:not(body):not(.focus-override) {
outline: none !important;
&:focus-visible {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
}
.link {
@apply inline-flex items-center gap-1 rounded-sm font-medium underline underline-offset-4 hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
}
Overview
The Accordion component is a versatile UI element that organizes content into collapsible sections, enabling users to focus on specific information while reducing visual clutter. It's particularly useful for presenting large amounts of related content in a compact, navigable format.
Key Features
- Customizable Behavior: Can be configured for single or multiple open sections.
- Accessibility: ARIA attributes for screen reader compatibility and keyboard navigation.
- Transition Support: CSS variables and data attributes for smooth transitions between states.
- Flexible State Management: Supports controlled and uncontrolled state, take control if needed.
- Compound Component Structure: Provides a set of sub-components that work together to create a fully-featured accordion.
Architecture
The Accordion component is composed of several sub-components, each with a specific role:
- Root: The root element that wraps all accordion items and manages the overall state.
- Item: Individual sections within the accordion.
- Trigger: The button that toggles the visibility of the content.
- Header: The title or heading of each item.
- Content: The expandable/collapsible body of each item.
Structure
Here's an overview of how the Accordion component is structured in code:
<script lang="ts">
import { Accordion } from "bits-ui";
</script>
<Accordion.Root>
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger />
</Accordion.Header>
<Accordion.Content />
</Accordion.Item>
</Accordion.Root>
Reusable Components
If you're planning to use the Accordion
component throughout your application, it's recommended to create reusable wrapper components to reduce the amount of code you need to write each time.
For each individual item, you need an Accordion.Item
, Accordion.Header
, Accordion.Trigger
and Accordion.Content
component. We can combine these into a single MyAccordionItem
component that makes it easier to reuse.
<script lang="ts">
import { Accordion, type WithoutChildrenOrChild } from "bits-ui";
type Props = WithoutChildrenOrChild<Accordion.ItemProps> & {
title: string;
content: string;
};
let { title, content, ...restProps }: Props = $props();
</script>
<Accordion.Item {...restProps}>
<Accordion.Header>
<Accordion.Trigger>{item.title}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
{content}
</Accordion.Content>
</Accordion.Item>
We used the WithoutChildrenOrChild
type helper to omit the child
and children
snippet props from Accordion.ItemProps
, since we are opting out of using delegation and are already taking care of rendering the children as text via the content
prop.
For our MyAccordion
component, we'll accept all the props that Accordion.Root
accepts, as well as an additional items
prop that will be used to render the MyAccordionItem
components.
<script lang="ts">
import { Accordion, type WithoutChildrenOrChild } from "bits-ui";
import MyAccordionItem from "$lib/components/MyAccordionItem.svelte";
type Item = {
value?: string;
title: string;
content: string;
disabled?: boolean;
};
let {
value = $bindable(),
ref = $bindable(null),
...restProps
}: WithoutChildrenOrChild<Accordion.RootProps> & {
items: Item[];
} = $props();
</script>
<!--
Since we have to destructure the `value` to make it `$bindable`, we need to use `as any` here to avoid
type errors from the discriminated union of `"single" | "multiple"`.
(an unfortunate consequence of having to destructure bindable values)
-->
<Accordion.Root bind:value bind:ref {...restProps as any}>
{#each items as item, i (item.title + i)}
<MyAccordionItem {...item} />
{/each}
</Accordion.Root>
<script lang="ts">
import { MyAccordion, MyAccordionItem } from "$lib/components";
</script>
<MyAccordion type="single">
<MyAccordionItem title="Item 1">Content 1</MyAccordionItem>
<MyAccordionItem title="Item 2">Content 2</MyAccordionItem>
<MyAccordionItem title="Item 3">Content 3</MyAccordionItem>
</MyAccordion>
Managing Value State
Bits UI offers several approaches to manage and synchronize the Accordion's value state, catering to different levels of control and integration needs.
1. Two-Way Binding
For seamless state synchronization, use Svelte's bind:value
directive. This method automatically keeps your local state in sync with the accordion's internal state.
<script lang="ts">
import { Accordion } from "bits-ui";
let myValue = $state<string[]>([]);
</script>
<button
onclick={() => {
myValue = ["item-1", "item-2"];
}}
>
Open Items 1 and 2
</button>
<Accordion.Root type="multiple" bind:value={myValue}>
<Accordion.Item value="item-1">
<!-- ... -->
</Accordion.Item>
<Accordion.Item value="item-2">
<!-- ... -->
</Accordion.Item>
<Accordion.Item value="item-3">
<!-- ... -->
</Accordion.Item>
</Accordion.Root>
Key Benefits
- Simplifies state management
- Automatically updates
myValue
when the accordion changes (e.g., via clicking on an item's trigger) - Allows external control (e.g., opening an item via a separate button)
2. Change Handler
For more granular control or to perform additional logic on state changes, use the onValueChange
prop. This approach is useful when you need to execute custom logic alongside state updates.
<script lang="ts">
import { Accordion } from "bits-ui";
let myValue = $state<string[]>([]);
</script>
<Accordion.Root
type="multiple"
value={myValue}
onValueChange={(value) => {
myValue = value;
// additional logic here.
}}
>
<Accordion.Item value="item-1">
<!-- ... -->
</Accordion.Item>
<Accordion.Item value="item-2">
<!-- ... -->
</Accordion.Item>
<Accordion.Item value="item-3">
<!-- ... -->
</Accordion.Item>
</Accordion.Root>
Use Cases
- Implementing custom behaviors on value change
- Integrating with external state management solutions
- Triggering side effects (e.g., logging, data fetching)
3. Fully Controlled
For complete control over the accordion's value state, use the controlledValue
prop. This approach requires you to manually manage the value state, giving you full control over when and how the accordion responds to value change events.
To implement controlled state:
- Set the
controlledValue
prop totrue
on theAccordion.Root
component. - Provide a
value
prop toAccordion.Root
, which should be a variable holding the current state. - Implement an
onValueChange
handler to update the state when the internal state changes.
<script lang="ts">
import { Accordion } from "bits-ui";
let myValue = $state("");
</script>
<Accordion.Root type="single" controlledValue value={myValue} onValueChange={(v) => (myValue = v)}>
<!-- ... -->
</Accordion.Root>
When to Use
- Implementing complex open/close logic
- Coordinating multiple UI elements
- Debugging state-related issues
Note
While powerful, fully controlled state should be used judiciously as it increases complexity and can cause unexpected behaviors if not handled carefully.
For more in-depth information on controlled components and advanced state management techniques, refer to our Controlled State documentation.
Single Type
Set the type
prop to "single"
to allow only one accordion item to be open at a time.
<CustomAccordion type="single" />
Multiple Type
Set the type
prop to "multiple"
to allow multiple accordion items to be open at the same time.
<CustomAccordion type="multiple" />
Default Open Items
To set default open items, pass them as the value
prop, which will be an array if the type
is "multiple"
, or a string if the type
is "single"
.
<CustomAccordion value={["A", "C"]} type="multiple" />
Disable Items
To disable an individual accordion item, set the disabled
prop to true
. This will prevent users from interacting with the item.
<Accordion.Root type="single">
<Accordion.Item value="item-1" disabled>
<!-- ... -->
</Accordion.Item>
</Accordion.Root>
Svelte Transitions
The Accordion component can be enhanced with Svelte's built-in transition effects or other animation libraries.
Using forceMount
and child
Snippets
To apply Svelte transitions to Accordion components, use the forceMount
prop in combination with the child
snippet. This approach gives you full control over the mounting behavior and animation of the Accordion.Content
.
<Accordion.Content forceMount={true}>
{#snippet child({ props, open })}
{#if open}
<div {...props} transition:slide={{ duration: 1000 }}>
This is the accordion content that will transition in and out.
</div>
{/if}
{/snippet}
</Accordion.Content>
In this example:
- The
forceMount
prop ensures the components are always in the DOM. - The
child
snippet provides access to the open state and component props. - Svelte's
#if
block controls when the content is visible. - Transition directives (
transition:fade
andtransition:fly
) apply the animations.
<script lang="ts">
import { Accordion } from "bits-ui";
import CaretDown from "phosphor-svelte/lib/CaretDown";
import { slide } from "svelte/transition";
const items = [
{
title: "What is the meaning of life?",
content:
"To become a better person, to help others, and to leave the world a better place than you found it."
},
{
title: "How do I become a better person?",
content:
"Read books, listen to podcasts, and surround yourself with people who inspire you."
},
{
title: "What is the best way to help others?",
content: "Give them your time, attention, and love."
}
];
let value = $state<string[]>([]);
</script>
<Accordion.Root class="w-full sm:max-w-[70%]" type="multiple" bind:value>
{#each items as item, i}
<Accordion.Item value={`${i}`} class="group border-b border-dark-10 px-1.5">
<Accordion.Header>
<Accordion.Trigger
class="flex w-full flex-1 items-center justify-between py-5 text-[15px] font-medium transition-all [&[data-state=open]>span>svg]:rotate-180"
>
{item.title}
<span
class="inline-flex size-8 items-center justify-center rounded-[7px] bg-transparent transition-all hover:bg-dark-10"
>
<CaretDown class="size-[18px] transition-all duration-200" />
</span>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content
forceMount={true}
class="overflow-hidden text-sm tracking-[-0.01em]"
>
{#snippet child({ props, open })}
{#if open}
<div {...props} transition:slide={{ duration: 1000 }}>
<div class="pb-[25px]">
{item.content}
</div>
</div>
{/if}
{/snippet}
</Accordion.Content>
</Accordion.Item>
{/each}
</Accordion.Root>
import typography from "@tailwindcss/typography";
import animate from "tailwindcss-animate";
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
container: {
center: true,
screens: {
"2xl": "1440px",
},
},
extend: {
colors: {
border: {
DEFAULT: "hsl(var(--border-card))",
input: "hsl(var(--border-input))",
"input-hover": "hsl(var(--border-input-hover))",
},
background: {
DEFAULT: "hsl(var(--background) / <alpha-value>)",
alt: "hsl(var(--background-alt) / <alpha-value>)",
},
foreground: {
DEFAULT: "hsl(var(--foreground) / <alpha-value>)",
alt: "hsl(var(--foreground-alt) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground))",
},
dark: {
DEFAULT: "hsl(var(--dark) / <alpha-value>)",
4: "hsl(var(--dark-04))",
10: "hsl(var(--dark-10))",
40: "hsl(var(--dark-40))",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
},
contrast: {
DEFAULT: "hsl(var(--contrast) / <alpha-value>)",
},
},
fontFamily: {
sans: ["Inter", ...fontFamily.sans],
mono: ["Source Code Pro", ...fontFamily.mono],
alt: ["Courier", ...fontFamily.sans],
},
fontSize: {
xxs: "10px",
},
borderWidth: {
6: "6px",
},
borderRadius: {
card: "16px",
"card-lg": "20px",
"card-sm": "10px",
input: "9px",
button: "5px",
"5px": "5px",
"9px": "9px",
"10px": "10px",
"15px": "15px",
},
height: {
input: "3rem",
"input-sm": "2.5rem",
},
boxShadow: {
mini: "var(--shadow-mini)",
"mini-inset": "var(--shadow-mini-inset)",
popover: "var(--shadow-popover)",
kbd: "var(--shadow-kbd)",
btn: "var(--shadow-btn)",
card: "var(--shadow-card)",
"date-field-focus": "var(--shadow-date-field-focus)",
},
opacity: {
8: "0.08",
},
scale: {
80: ".80",
98: ".98",
99: ".99",
},
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--bits-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--bits-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
plugins: [typography, animate],
};
@import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Colors */
--background: 0 0% 100%;
--background-alt: 0 0% 100%;
--foreground: 0 0% 9%;
--foreground-alt: 0 0% 32%;
--muted: 240 5% 96%;
--muted-foreground: 0 0% 9% / 0.4;
--border: 240 6% 10%;
--border-input: 240 6% 10% / 0.17;
--border-input-hover: 240 6% 10% / 0.4;
--border-card: 240 6% 10% / 0.1;
--dark: 240 6% 10%;
--dark-10: 240 6% 10% / 0.1;
--dark-40: 240 6% 10% / 0.4;
--dark-04: 240 6% 10% / 0.04;
--accent: 204 94% 94%;
--accent-foreground: 204 80% 16%;
--destructive: 347 77% 50%;
/* black */
--constrast: 0 0% 0%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.04) inset;
--shadow-popover: 0px 7px 12px 3px hsla(var(--dark-10));
--shadow-kbd: 0px 2px 0px 0px rgba(0, 0, 0, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.03);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(24, 24, 27, 0.17);
}
.dark {
/* Colors */
--background: 0 0% 5%;
--background-alt: 0 0% 8%;
--foreground: 0 0% 95%;
--foreground-alt: 0 0% 70%;
--muted: 240 4% 16%;
--muted-foreground: 0 0% 100% / 0.4;
--border: 0 0% 96%;
--border-input: 0 0% 96% / 0.17;
--border-input-hover: 0 0% 96% / 0.4;
--border-card: 0 0% 96% / 0.1;
--dark: 0 0% 96%;
--dark-40: 0 0% 96% / 0.4;
--dark-10: 0 0% 96% / 0.1;
--dark-04: 0 0% 96% / 0.04;
--accent: 204 90 90%;
--accent-foreground: 204 94% 94%;
--destructive: 350 89% 60%;
/* white */
--constrast: 0 0% 100%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.3);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.5) inset;
--shadow-popover: 0px 7px 12px 3px hsla(0deg 0% 0% / 30%);
--shadow-kbd: 0px 2px 0px 0px rgba(255, 255, 255, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.2);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.4);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(244, 244, 245, 0.1);
}
}
@layer base {
* {
@apply border-border;
}
html {
-webkit-text-size-adjust: 100%;
font-variation-settings: normal;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
/* Mobile tap highlight */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color */
html {
-webkit-tap-highlight-color: rgba(128, 128, 128, 0.5);
}
::selection {
background: #fdffa4;
color: black;
}
/* === Scrollbars === */
::-webkit-scrollbar {
@apply w-2;
@apply h-2;
}
::-webkit-scrollbar-track {
@apply !bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply rounded-card-lg !bg-dark-10;
}
::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0);
}
/* Firefox */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color#browser_compatibility */
html {
scrollbar-color: var(--bg-muted);
}
.antialised {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full border-4 border-background bg-muted text-center -indent-px font-mono text-base font-medium;
@apply ml-[-50px] mt-[-4px];
content: counter(step);
}
}
@layer components {
*:not(body):not(.focus-override) {
outline: none !important;
&:focus-visible {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
}
.link {
@apply inline-flex items-center gap-1 rounded-sm font-medium underline underline-offset-4 hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
}
Best Practices
For cleaner code and better maintainability, consider creating custom reusable components that encapsulate this transition logic.
<script lang="ts">
import { Accordion, type WithoutChildrenOrChild } from "bits-ui";
import type { Snippet } from "svelte";
import { fade } from "svelte/transition";
let {
ref = $bindable(null),
duration = 200,
children,
...restProps
}: WithoutChildrenOrChild<Accordion.ContentProps> & {
duration?: number;
children: Snippet;
} = $props();
</script>
<Accordion.Content forceMount bind:ref {...restProps}>
{#snippet child({ props, open })}
{#if open}
<div {...props} transition:fade={{ duration }}>
{@render children?.()}
</div>
{/if}
{/snippet}
</Accordion.Content>
You can then use the MyAccordionContent
component alongside the other Accordion
primitives throughout your application:
<Accordion.Root>
<Accordion.Item value="A">
<Accordion.Header>
<Accordion.Trigger>A</Accordion.Trigger>
</Accordion.Header>
<MyAccordionContent duration={300}>
<!-- ... -->
</MyAccordionContent>
</Accordion.Item>
</Accordion.Root>
API Reference
The root accordion component used to set and manage the state of the accordion.
Property | Type | Description |
---|---|---|
type required | enum | The type of accordion. If set to Default: undefined |
value $bindable | union | The value of the currently active accordion item. If Default: undefined |
onValueChange | function | A callback function called when the active accordion item value changes. If the Default: undefined |
controlledValue | boolean | Whether or not the Default: false |
disabled | boolean | Whether or not the accordion is disabled. When disabled, the accordion cannot be interacted with. Default: false |
loop | boolean | Whether or not the accordion should loop through items when reaching the end. Default: false |
orientation | enum | The orientation of the accordion. Default: vertical |
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-orientation | enum | The orientation of the component. |
data-disabled | '' | Present when the component is disabled. |
data-accordion-root | '' | Present on the root element. |
An accordion item.
Property | Type | Description |
---|---|---|
disabled | boolean | Whether or not the accordion item is disabled. Default: false |
value | string | The value of the accordion item. This is used to identify when the item is open or closed. If not provided, a unique ID will be generated for this value. Default: A random unique ID |
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-state | enum | Whether the accordion item is open or closed. |
data-disabled | '' | Present when the component is disabled. |
data-orientation | enum | The orientation of the component. |
data-accordion-item | '' | Present on the item element. |
The header of the accordion item.
Property | Type | Description |
---|---|---|
level | union | The heading level of the header. This will be set as the Default: 3 |
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-orientation | enum | The orientation of the component. |
data-disabled | '' | Present when the component is disabled. |
data-heading-level | enum | The heading level of the element. |
data-accordion-header | '' | Present on the header element. |
The button responsible for toggling the accordion item.
Property | Type | Description |
---|---|---|
ref $bindable | HTMLButtonElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-orientation | enum | The orientation of the component. |
data-disabled | '' | Present when the component is disabled. |
data-accordion-trigger | '' | Present on the trigger element. |
The accordion item content, which is displayed when the item is open.
Property | Type | Description |
---|---|---|
forceMount | boolean | Whether or not to forcefully mount the content. This is useful if you want to use Svelte transitions or another animation library for the content. Default: false |
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-orientation | enum | The orientation of the component. |
data-disabled | '' | Present when the component is disabled. |
data-accordion-content | '' | Present on the content element. |
CSS Variable | Description |
---|---|
--bits-accordion-content-height | The height of the accordion content element. |
--bits-accordion-content-width | The width of the accordion content element. |