Collapsible
Conceals or reveals content sections, enhancing space utilization and organization.
@huntabyte starred 3 repositories
<script lang="ts">
import { Collapsible } from "bits-ui";
import CaretUpDown from "phosphor-svelte/lib/CaretUpDown";
</script>
<Collapsible.Root class="w-[327px] space-y-3">
<div class="flex items-center justify-between space-x-10">
<h4 class="text-[15px] font-medium">@huntabyte starred 3 repositories</h4>
<Collapsible.Trigger
class="rounded-9px border-border-input bg-background-alt text-foreground shadow-btn hover:bg-muted inline-flex h-10 w-10 items-center justify-center border transition-all active:scale-[0.98]"
aria-label="Show starred repositories"
>
<CaretUpDown class="size-4" weight="bold" />
</Collapsible.Trigger>
</div>
<Collapsible.Content
class="space-y-2 font-mono text-[15px] tracking-[0.01em]"
>
<div
class="rounded-9px bg-muted inline-flex h-12 w-full items-center px-[18px] py-3"
>
@huntabyte/bits-ui
</div>
<div
class="rounded-9px bg-muted inline-flex h-12 w-full items-center px-[18px] py-3"
>
@huntabyte/shadcn-svelte
</div>
<div
class="rounded-9px bg-muted inline-flex h-12 w-full items-center px-[18px] py-3"
>
@svecosystem/runed
</div>
</Collapsible.Content>
</Collapsible.Root>
@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");
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@font-face {
font-family: "Cal Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/CalSans-SemiBold.woff2") format("woff2");
}
:root {
/* Colors */
--background: hsl(0 0% 100%);
--background-alt: hsl(0 0% 100%);
--foreground: hsl(0 0% 9%);
--foreground-alt: hsl(0 0% 32%);
--muted: hsl(240 5% 96%);
--muted-foreground: hsla(0 0% 9% / 0.4);
--border: hsl(240 6% 10%);
--border-input: hsla(240 6% 10% / 0.17);
--border-input-hover: hsla(240 6% 10% / 0.4);
--border-card: hsla(240 6% 10% / 0.1);
--dark: hsl(240 6% 10%);
--dark-10: hsla(240 6% 10% / 0.1);
--dark-40: hsla(240 6% 10% / 0.4);
--dark-04: hsla(240 6% 10% / 0.04);
--accent: hsl(204 94% 94%);
--accent-foreground: hsl(204 80% 16%);
--destructive: hsl(347 77% 50%);
--tertiary: hsl(37.7 92.1% 50.2%);
--line: hsl(0 0% 100%);
/* black */
--contrast: hsl(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: hsl(0 0% 5%);
--background-alt: hsl(0 0% 8%);
--foreground: hsl(0 0% 95%);
--foreground-alt: hsl(0 0% 70%);
--muted: hsl(240 4% 16%);
--muted-foreground: hsla(0 0% 100% / 0.4);
--border: hsl(0 0% 96%);
--border-input: hsla(0 0% 96% / 0.17);
--border-input-hover: hsla(0 0% 96% / 0.4);
--border-card: hsla(0 0% 96% / 0.1);
--dark: hsl(0 0% 96%);
--dark-40: hsl(0 0% 96% / 0.4);
--dark-10: hsl(0 0% 96% / 0.1);
--dark-04: hsl(0 0% 96% / 0.04);
--accent: hsl(204 90% 90%);
--accent-foreground: hsl(204 94% 94%);
--destructive: hsl(350 89% 60%);
--line: hsl(0 0% 9.02%);
--tertiary: hsl(61.3 100% 82.2%);
/* white */
--contrast: hsl(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);
}
@theme inline {
--color-background: var(--background);
--color-background-alt: var(--background-alt);
--color-foreground: var(--foreground);
--color-foreground-alt: var(--foreground-alt);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border-card);
--color-border-input: var(--border-input);
--color-border-input-hover: var(--border-input-hover);
--color-border-card: var(--border-card);
--color-dark: var(--dark);
--color-dark-10: var(--dark-10);
--color-dark-40: var(--dark-40);
--color-dark-04: var(--dark-04);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-tertiary: var(--tertiary);
--color-line: var(--line);
--color-contrast: var(--contrast);
--shadow-mini: var(--shadow-mini);
--shadow-mini-inset: var(--shadow-mini-inset);
--shadow-popover: var(--shadow-popover);
--shadow-kbd: var(--shadow-kbd);
--shadow-btn: var(--shadow-btn);
--shadow-card: var(--shadow-card);
--shadow-date-field-focus: var(--shadow-date-field-focus);
--text-xxs: 10px;
--radius-card: 16px;
--radius-card-lg: 20px;
--radius-card-sm: 10px;
--radius-input: 9px;
--radius-button: 5px;
--radius-5px: 5px;
--radius-9px: 9px;
--radius-10px: 10px;
--radius-15px: 15px;
--spacing-input: 3rem;
--spacing-input-sm: 2.5rem;
--breakpoint-desktop: 1440px;
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-caret-blink: caret-blink 1s ease-out infinite;
--animate-scale-in: scale-in 0.2s ease;
--animate-scale-out: scale-out 0.15s ease;
--animate-fade-in: fade-in 0.2s ease;
--animate-fade-out: fade-out 0.15s ease;
--animate-enter-from-left: enter-from-left 0.2s ease;
--animate-enter-from-right: enter-from-right 0.2s ease;
--animate-exit-to-left: exit-to-left 0.2s ease;
--animate-exit-to-right: exit-to-right 0.2s ease;
--font-sans: "Inter", "sans-serif";
--font-mono: "Source Code Pro", "monospace";
--font-alt: "Courier", "sans-serif";
--font-display: "Cal Sans", "sans-serif";
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--bits-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--bits-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
}
}
@keyframes enter-from-right {
from {
opacity: 0;
transform: translateX(200px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes enter-from-left {
from {
opacity: 0;
transform: translateX(-200px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes exit-to-right {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(200px);
}
}
@keyframes exit-to-left {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-200px);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: rotateX(-10deg) scale(0.9);
}
to {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
}
@keyframes scale-out {
from {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
to {
opacity: 0;
transform: rotateX(-10deg) scale(0.95);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-border-card, currentColor);
}
* {
@apply border-border;
}
html {
-webkit-text-size-adjust: 100%;
font-variation-settings: normal;
scrollbar-color: var(--bg-muted);
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
::selection {
background: #fdffa4;
color: black;
}
}
@layer components {
*:not(body):not(.focus-override) {
outline: none !important;
&:focus-visible {
@apply focus-visible:ring-foreground focus-visible:ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1;
}
}
.link {
@apply hover:text-foreground/80 focus-visible:ring-foreground focus-visible:ring-offset-background rounded-xs focus-visible:outline-hidden inline-flex items-center gap-1 font-medium underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-offset-2;
}
}
Overview
The Collapsible component enables you to create expandable and collapsible content sections. It provides an efficient way to manage space and organize information in user interfaces, enabling users to show or hide content as needed.
Key Features
- 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 collapsible.
Architecture
The Collapsible component is composed of a few sub-components, each with a specific role:
- Root: The parent container that manages the state and context for the collapsible functionality.
- Trigger: The interactive element (e.g., button) that toggles the expanded/collapsed state of the content.
- Content: The container for the content that will be shown or hidden based on the collapsible state.
Structure
Here's an overview of how the Collapsible component is structured in code:
<script lang="ts">
import { Collapsible } from "bits-ui";
</script>
<Collapsible.Root>
<Collapsible.Trigger />
<Collapsible.Content />
</Collapsible.Root>
Reusable Components
It's recommended to use the Collapsible
primitives to create your own custom collapsible component that can be used throughout your application.
<script lang="ts">
import { Collapsible, type WithoutChild } from "bits-ui";
type Props = WithoutChild<Collapsible.RootProps> & {
buttonText: string;
};
let {
open = $bindable(false),
ref = $bindable(null),
buttonText,
children,
...restProps
}: Props = $props();
</script>
<Collapsible.Root bind:open bind:ref {...restProps}>
<Collapsible.Trigger>{buttonText}</Collapsible.Trigger>
<Collapsible.Content>
{@render children?.()}
</Collapsible.Content>
</Collapsible.Root>
You can then use the MyCollapsible
component in your application like so:
<script lang="ts">
import MyCollapsible from "$lib/components/MyCollapsible.svelte";
</script>
<MyCollapsible buttonText="Open Collapsible">Here is my collapsible content.</MyCollapsible>
Managing Open State
This section covers how to manage the open
state of the Collapsible.
Two-Way Binding
Use bind:open
for simple, automatic state synchronization:
<script lang="ts">
import { Collapsible } from "bits-ui";
let isOpen = $state(false);
</script>
<button onclick={() => (isOpen = true)}>Open Collapsible</button>
<Collapsible.Root bind:open={isOpen}>
<!-- ... -->
</Collapsible.Root>
Fully Controlled
Use a Function Binding for complete control over the state's reads and writes.
<script lang="ts">
import { Collapsible } from "bits-ui";
let myOpen = $state(false);
function getOpen() {
return myOpen;
}
function setOpen(newOpen: boolean) {
myOpen = newOpen;
}
</script>
<Collapsible.Root bind:open={getOpen, setOpen}>
<!-- ... -->
</Collapsible.Root>
Svelte Transitions
The Collapsible 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 Collapsible 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 Collapsible.Content
.
<script lang="ts">
import { Collapsible } from "bits-ui";
import { fade } from "svelte/transition";
</script>
<Collapsible.Root>
<Collapsible.Trigger>Open</Collapsible.Trigger>
<Collapsible.Content forceMount>
{#snippet child({ props, open })}
{#if open}
<div {...props} transition:fade>
<!-- ... -->
</div>
{/if}
{/snippet}
</Collapsible.Content>
</Collapsible.Root>
In this example:
- The
forceMount
prop ensures the content is 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 directive (
transition:fade
) apply the animations.
Best Practices
For cleaner code and better maintainability, consider creating custom reusable components that encapsulate this transition logic.
<script lang="ts">
import { Collapsible, type WithoutChildrenOrChild } from "bits-ui";
import { fade } from "svelte/transition";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
duration = 200,
children,
...restProps
}: WithoutChildrenOrChild<Collapsible.ContentProps> & {
duration?: number;
children?: Snippet;
} = $props();
</script>
<Collapsible.Content forceMount bind:ref {...restProps}>
{#snippet child({ props, open })}
{#if open}
<div {...props} transition:fade={{ duration }}>
{@render children?.()}
</div>
{/if}
{/snippet}
</Collapsible.Content>
You can then use the MyCollapsibleContent
component alongside the other Collapsible
primitives throughout your application:
<script lang="ts">
import { Collapsible } from "bits-ui";
import { MyCollapsibleContent } from "$lib/components";
</script>
<Collapsible.Root>
<Collapsible.Trigger>Open</Collapsible.Trigger>
<MyCollapsibleContent duration={300}>
<!-- ... -->
</MyCollapsibleContent>
</Collapsible.Root>
API Reference
The root collapsible container which manages the state of the collapsible.
Property | Type | Description |
---|---|---|
open $bindable | boolean | The open state of the collapsible. The content will be visible when this is true, and hidden when it's false. Default: false |
onOpenChange | function | A callback that is fired when the collapsible's open state changes. Default: undefined |
disabled | boolean | Whether or not the collapsible is disabled. This prevents the user from interacting with it. 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-state | enum | The collapsible's open state. |
data-disabled | '' | Present when the collapsible is disabled. |
data-collapsible-root | '' | Present on the root element. |
The button responsible for toggling the collapsible's open state.
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-state | enum | The collapsible's open state. |
data-disabled | '' | Present when the collapsible or this trigger is disabled. |
data-collapsible-trigger | '' | Present on the trigger element. |
The content displayed when the collapsible 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-state | enum | The collapsible's open state. |
data-disabled | '' | Present when the collapsible is disabled. |
data-collapsible-content | '' | Present on the content element. |
CSS Variable | Description |
---|---|
--bits-collapsible-content-height | The height of the collapsible content element. |
--bits-collapsible-content-width | The width of the collapsible content element. |