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="inline-flex h-10 w-10 items-center justify-center rounded-9px border border-border-input bg-background-alt text-foreground shadow-btn transition-all hover:bg-muted active:scale-98"
>
<CaretUpDown class="size-4 " weight="bold" />
<span class="sr-only">Toggle</span>
</Collapsible.Trigger>
</div>
<Collapsible.Content
class="space-y-2 font-mono text-[15px] tracking-[0.01em]"
>
<div
class="inline-flex h-12 w-full items-center rounded-9px bg-muted px-[18px] py-3"
>
@huntabyte/bits-ui
</div>
<div
class="inline-flex h-12 w-full items-center rounded-9px bg-muted px-[18px] py-3"
>
@huntabyte/shadcn-svelte
</div>
<div
class="inline-flex h-12 w-full items-center rounded-9px bg-muted px-[18px] py-3"
>
@melt-ui/melt-ui
</div>
</Collapsible.Content>
</Collapsible.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;
}
}
Structure
<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
The open
prop is used to determine whether the collapsible is open or closed. Bits UI provides flexible options for controlling and synchronizing the Collapsible's open state.
Two-Way Binding
Use the bind:open
directive for effortless two-way synchronization between your local state and the Collapsible's internal state.
<script lang="ts">
import { Collapsible } from "bits-ui";
let myOpen = $state(false);
</script>
<button onclick={() => (myOpen = true)}> Open </button>
<Collapsible.Root bind:open={myOpen}>
<!-- ... -->
</Collapsible.Root>
This setup enables toggling the Collapsible via the custom button and ensures the local myOpen
state updates when the Collapsible changes through any internal means (e.g., clicking on the trigger).
Change Handler
You can also use the onOpenChange
prop to update local state when the Collapsible's open
state changes. This is useful when you don't want two-way binding for one reason or another, or you want to perform additional logic when the Collapsible changes.
<script lang="ts">
import { Collapsible } from "bits-ui";
let myOpen = $state(false);
</script>
<Collapsible.Root
open={myOpen}
onOpenChange={(open) => {
myOpen = open;
// additional logic here.
}}
>
<!-- ... -->
</Collapsible.Root>
Svelte Transitions
You can use the forceMount
prop on the Collapsible.Content
component to forcefully mount the content regardless of whether the collapsible is opened or not. This is useful when you want more control over the transitions when the collapsible opens and closes using something like Svelte Transitions.
The open
snippet prop can be used for conditional rendering of the content based on whether the collapsible is open.
<Collapsible.Content forceMount>
{#snippet child({ props, open })}
{#if open}
<div {...props} transition:slide={{ duration: 1000 }}>
This is the collapsible content that will transition in and out.
</div>
{/if}
{/snippet}
</Collapsible.Content>
With the amount of boilerplate needed to handle the transitions, it's recommended to componentize your custom implementation of the collapsible content and use that throughout your application. See the Transitions documentation for more information on using transitions with Bits UI components.
API Reference
The root collapsible container which manages the state of the collapsible.
Property | Type | Description |
---|---|---|
open bindable prop | boolean | The open state of the collapsible. The content will be visible when this is true, and hidden when it's false. Default: false |
disabled | boolean | Whether or not the collapsible is disabled. This prevents the user from interacting with it. Default: false |
onOpenChange | function | A callback that is fired when the collapsible's open state changes. Default: undefined |
ref bindable prop | 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 delegation 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 prop | 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 delegation 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 prop | 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 delegation 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. |