Skip to content
edmi-ui

Bubble

Conversational content in a bubble: seven variants, start/end alignment, grouping and floating reactions.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
default bubble
secondary bubble
muted bubble
tinted bubble
outline bubble
ghost bubble
destructive bubble
Run it and send me the transaction.
The keeper just rebalanced MAG4.
default bubble
secondary bubble
muted bubble
tinted bubble
outline bubble
ghost bubble
destructive bubble
Run it and send me the transaction.
The keeper just rebalanced MAG4.
default bubble
secondary bubble
muted bubble
tinted bubble
outline bubble
ghost bubble
destructive bubble
Run it and send me the transaction.
The keeper just rebalanced MAG4.
Terminal window
npx shadcn@latest add @edmi-ui/bubble
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/bubble
Terminal window
yarn dlx shadcn@latest add @edmi-ui/bubble
Terminal window
bunx shadcn@latest add @edmi-ui/bubble

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/bubble
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/bubble
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/bubble
Terminal window
bunx shadcn-vue@latest add @edmi-ui/bubble

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/bubble.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/bubble.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/bubble.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/bubble.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReaction,
BubbleReactions,
} from "@/components/ui/bubble";
<div className="flex w-full max-w-md flex-col gap-4">
<BubbleGroup>
</BubbleGroup>
<BubbleGroup>
<Bubble align="end">
<BubbleContent>Run it and send me the transaction.</BubbleContent>
</Bubble>
</BubbleGroup>
<Bubble variant="secondary" className="mb-3">
<BubbleContent>The keeper just rebalanced MAG4.</BubbleContent>
<BubbleReactions>
<BubbleReaction active>👍 4</BubbleReaction>
<BubbleReaction>🚀 2</BubbleReaction>
<BubbleReaction aria-label="Add reaction">
<SmileyIcon className="size-3" />
</BubbleReaction>
</BubbleReactions>
</Bubble>
</div>
<script setup lang="ts">
import {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReaction,
BubbleReactions,
} from "@/components/ui/bubble";
</script>
<template>
<div class="flex w-full max-w-md flex-col gap-4">
<BubbleGroup>
<Bubble :key="variant" :variant="variant">
<BubbleContent>{{ variant }} bubble</BubbleContent>
</Bubble>
</BubbleGroup>
<BubbleGroup>
<Bubble align="end">
<BubbleContent>Run it and send me the transaction.</BubbleContent>
</Bubble>
</BubbleGroup>
<Bubble variant="secondary" class="mb-3">
<BubbleContent>The keeper just rebalanced MAG4.</BubbleContent>
<BubbleReactions>
<BubbleReaction active>👍 4</BubbleReaction>
<BubbleReaction>🚀 2</BubbleReaction>
<BubbleReaction aria-label="Add reaction">
<SmileIcon class="size-3" />
</BubbleReaction>
</BubbleReactions>
</Bubble>
</div>
</template>
<script lang="ts">
import * as Bubble from "$lib/components/ui/bubble";
</script>
<div class="flex w-full max-w-md flex-col gap-4">
<Bubble.Group>
{#each variants as variant (variant)}
<Bubble.Root {variant}>
<Bubble.Content>{variant} bubble</Bubble.Content>
</Bubble.Root>
{/each}
</Bubble.Group>
<Bubble.Group>
<Bubble.Root align="end">
<Bubble.Content>Run it and send me the transaction.</Bubble.Content>
</Bubble.Root>
</Bubble.Group>
<Bubble.Root variant="secondary" class="mb-3">
<Bubble.Content>The keeper just rebalanced MAG4.</Bubble.Content>
<Bubble.Reactions>
<Bubble.Reaction active>👍 4</Bubble.Reaction>
<Bubble.Reaction>🚀 2</Bubble.Reaction>
<Bubble.Reaction aria-label="Add reaction">☺</Bubble.Reaction>
</Bubble.Reactions>
</Bubble.Root>
</div>
registry/ui/bubble.tsx
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import * as React from "react";
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
);
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_srgb,var(--primary)_85%,var(--background))]",
secondary:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
muted:
"*:data-[slot=bubble-content]:bg-muted *:data-[slot=bubble-content]:text-muted-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
tinted:
"*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--brand)_25%,var(--popover))] *:data-[slot=bubble-content]:bg-brand-soft *:data-[slot=bubble-content]:text-foreground",
outline:
"*:data-[slot=bubble-content]:border-input *:data-[slot=bubble-content]:bg-transparent [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
// Ghost keeps its vertical padding so avatars line up with the first line.
ghost:
"*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:px-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
destructive:
"*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] *:data-[slot=bubble-content]:bg-destructive-soft *:data-[slot=bubble-content]:text-destructive-text",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end";
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
);
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-2xl border border-transparent px-[13px] py-[9px] text-sm leading-normal wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:outline-2 [button,a]:focus-visible:outline-offset-2 [button,a]:focus-visible:outline-ring",
className,
),
},
props,
),
render,
state: {
slot: "bubble-content",
},
});
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center gap-1",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
},
);
// ✦ `elevation` on BubbleReactions flows to every BubbleReaction chip.
const BubbleReactionsContext = React.createContext<{ raised: boolean }>({
raised: false,
});
function BubbleReactions({
side = "bottom",
align = "end",
elevation,
className,
...props
}: React.ComponentProps<"div"> & {
/** ✦ depth of the chips: raised +1 / floating +2 bevel every chip (`active` kept). */
elevation?: Elevation;
align?: "start" | "end";
side?: "top" | "bottom";
}) {
const level = useElevation(elevation, "control");
return (
<BubbleReactionsContext.Provider
value={{ raised: level === "raised" || level === "floating" }}
>
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
</BubbleReactionsContext.Provider>
);
}
/** ✦ Edmi addition: a single solid floating chip (DESIGN §4.6, no ring, 1px border). */
const bubbleReactionVariants = cva(
"inline-flex h-[22px] items-center gap-1 rounded-full border border-border bg-popover px-[7px] text-[11.5px] text-foreground",
{
variants: {
active: {
true: "border-[color-mix(in_srgb,var(--brand)_45%,var(--popover))] bg-[color-mix(in_srgb,var(--brand)_14%,var(--popover))] text-brand-text",
false: "",
},
// ✦ elevation: the chip bevels (flat is the default)
elevation: {
flat: "",
raised: "border-transparent shadow-btn-raised-neutral",
},
},
defaultVariants: { active: false, elevation: "flat" },
},
);
function BubbleReaction({
active = false,
elevation,
className,
render,
...props
}: useRender.ComponentProps<"button"> & {
active?: boolean;
/** ✦ depth of this chip; defaults to the BubbleReactions level. */
elevation?: Elevation;
}) {
const context = React.useContext(BubbleReactionsContext);
const own =
elevation && elevation !== "auto"
? elevation === "raised" || elevation === "floating"
: undefined;
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: "button",
"aria-pressed": !!active,
className: cn(
bubbleReactionVariants({
active,
elevation: (own ?? context.raised) ? "raised" : "flat",
}),
"outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className,
),
},
props,
),
render,
state: { slot: "bubble-reaction", active: !!active },
});
}
export {
Bubble,
BubbleContent,
BubbleGroup,
BubbleReaction,
BubbleReactions,
bubbleReactionVariants,
bubbleVariants,
};
registry/ui/bubble/Bubble.vue
<script lang='ts' setup>
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BubbleVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/registry/edmi/lib/utils"
import { bubbleVariants } from "."
interface Props extends PrimitiveProps {
variant?: BubbleVariants["variant"]
align?: "start" | "end"
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
variant: "default",
align: "start",
as: "div",
})
</script>
<template>
<Primitive
data-slot="bubble"
:data-variant="variant"
:data-align="align"
:as="as"
:as-child="asChild"
:class="cn(bubbleVariants({ variant }), props.class)"
>
<slot />
</Primitive>
</template>
registry/ui/bubble/BubbleContent.vue
<script lang='ts' setup>
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { cn } from "@/registry/edmi/lib/utils"
interface Props extends PrimitiveProps {
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "div",
})
</script>
<template>
<Primitive
data-slot="bubble-content"
:as="as"
:as-child="asChild"
:class="cn(
'w-fit max-w-full min-w-0 overflow-hidden rounded-2xl border border-transparent px-[13px] py-[9px] text-sm leading-normal wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:outline-2 [button,a]:focus-visible:outline-offset-2 [button,a]:focus-visible:outline-ring',
props.class,
)"
>
<slot />
</Primitive>
</template>
registry/ui/bubble/BubbleGroup.vue
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { cn } from "@/registry/edmi/lib/utils"
interface Props extends PrimitiveProps {
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "div",
})
</script>
<template>
<Primitive
data-slot="bubble-group"
:as="as"
:as-child="asChild"
:class="cn('gap-2 flex min-w-0 flex-col', props.class)"
>
<slot />
</Primitive>
</template>
registry/ui/bubble/BubbleReaction.vue
<script lang="ts" setup>
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { computed, inject } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import type { Elevation } from "@/registry/edmi/ui/elevation"
import { bubbleReactionVariants } from "."
/** ✦ Edmi addition: a single floating reaction chip. */
interface Props extends PrimitiveProps {
active?: boolean
/** ✦ overrides the BubbleReactions `elevation` for this chip */
elevation?: Elevation
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
active: false,
as: "button",
elevation: undefined,
})
const context = inject<{ raised?: boolean } | null>("bubbleReactions", null)
const raised = computed(() =>
props.elevation && props.elevation !== "auto"
? props.elevation === "raised" || props.elevation === "floating"
: !!context?.raised,
)
</script>
<template>
<Primitive
data-slot="bubble-reaction"
:data-active="active ? '' : undefined"
:type="as === 'button' ? 'button' : undefined"
:aria-pressed="active"
:as="as"
:as-child="asChild"
:class="cn(bubbleReactionVariants({ active, elevation: raised ? 'raised' : 'flat' }), props.class)"
>
<slot />
</Primitive>
</template>
registry/ui/bubble/BubbleReactions.vue
<script lang='ts' setup>
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BubbleReactionsVariants } from "."
import { Primitive } from "reka-ui"
import { computed, provide } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation"
import { bubbleReactionsVariants } from "."
interface Props extends PrimitiveProps {
side?: BubbleReactionsVariants["side"]
align?: BubbleReactionsVariants["align"]
/** ✦ depth of the chips: raised +1 / floating +2 bevel every chip (chips may override) */
elevation?: Elevation
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
side: "bottom",
align: "end",
as: "div",
elevation: undefined,
})
const level = useElevation(() => props.elevation, "control")
const raised = computed(() => level.value === "raised" || level.value === "floating")
provide("bubbleReactions", {
get raised() { return raised.value },
})
</script>
<template>
<Primitive
data-slot="bubble-reactions"
:data-side="side"
:data-align="align"
:as="as"
:as-child="asChild"
:class="cn(bubbleReactionsVariants({ side, align }), props.class)"
>
<slot />
</Primitive>
</template>
registry/ui/bubble/index.ts
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as Bubble } from "./Bubble.vue";
export { default as BubbleContent } from "./BubbleContent.vue";
export { default as BubbleGroup } from "./BubbleGroup.vue";
export { default as BubbleReaction } from "./BubbleReaction.vue";
export { default as BubbleReactions } from "./BubbleReactions.vue";
export const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_srgb,var(--primary)_85%,var(--background))]",
secondary:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
muted:
"*:data-[slot=bubble-content]:bg-muted *:data-[slot=bubble-content]:text-muted-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
tinted:
"*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--brand)_25%,var(--popover))] *:data-[slot=bubble-content]:bg-brand-soft *:data-[slot=bubble-content]:text-foreground",
outline:
"*:data-[slot=bubble-content]:border-input *:data-[slot=bubble-content]:bg-transparent [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
// Ghost keeps its vertical padding so avatars line up with the first line.
ghost:
"*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:px-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
destructive:
"*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] *:data-[slot=bubble-content]:bg-destructive-soft *:data-[slot=bubble-content]:text-destructive-text",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center gap-1",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
},
);
export type BubbleReactionsVariants = VariantProps<
typeof bubbleReactionsVariants
>;
/** ✦ Edmi addition: a single solid floating chip (DESIGN §4.6, no ring, 1px border). */
export const bubbleReactionVariants = cva(
"inline-flex h-[22px] items-center gap-1 rounded-full border border-border bg-popover px-[7px] text-[11.5px] text-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
{
variants: {
active: {
true: "border-[color-mix(in_srgb,var(--brand)_45%,var(--popover))] bg-[color-mix(in_srgb,var(--brand)_14%,var(--popover))] text-brand-text",
false: "",
},
// ✦ elevation: the chip bevels (flat is the default)
elevation: {
flat: "",
raised: "border-transparent shadow-btn-raised-neutral",
},
},
defaultVariants: { active: false, elevation: "flat" },
},
);
export type BubbleReactionVariants = VariantProps<
typeof bubbleReactionVariants
>;
src/lib/registry/ui/bubble/bubble-content.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes } from "svelte/elements";
// Renders a `<div>`; pass `href` for an `<a>` or `as="button"` for a `<button>`.
let {
ref = $bindable(null),
class: className,
as = "div",
href = undefined,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> &
WithElementRef<HTMLAnchorAttributes> &
WithElementRef<HTMLButtonAttributes> & { as?: "div" | "button" | "a" } = $props();
const classes = $derived(
cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-2xl border border-transparent px-[13px] py-[9px] text-sm leading-normal wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:outline-2 [button,a]:focus-visible:outline-offset-2 [button,a]:focus-visible:outline-ring",
className
)
);
</script>
{#if href || as === "a"}
<a bind:this={ref} data-slot="bubble-content" class={classes} {href} {...restProps}>
{@render children?.()}
</a>
{:else if as === "button"}
<button bind:this={ref} data-slot="bubble-content" class={classes} type="button" {...restProps}>
{@render children?.()}
</button>
{:else}
<div bind:this={ref} data-slot="bubble-content" class={classes} {...restProps}>
{@render children?.()}
</div>
{/if}
src/lib/registry/ui/bubble/bubble-group.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="bubble-group"
class={cn(
"flex min-w-0 flex-col gap-2",
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/bubble/bubble-reaction.svelte
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
/** ✦ Edmi addition: a single solid floating chip (DESIGN §4.6, no ring, 1px border). */
export const bubbleReactionVariants = tv({
base: "inline-flex h-[22px] items-center gap-1 rounded-full border border-border bg-popover px-[7px] text-[11.5px] text-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
variants: {
active: {
true: "border-[color-mix(in_srgb,var(--brand)_45%,var(--popover))] bg-[color-mix(in_srgb,var(--brand)_14%,var(--popover))] text-brand-text",
false: "",
},
// ✦ elevation: the chip bevels (flat is the default)
elevation: { flat: "", raised: "border-transparent shadow-btn-raised-neutral" },
},
defaultVariants: { active: false, elevation: "flat" },
});
export type BubbleReactionVariants = VariantProps<typeof bubbleReactionVariants>;
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLButtonAttributes } from "svelte/elements";
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
import { getBubbleReactionsCtx } from "./bubble-reactions.svelte";
let {
ref = $bindable(null),
class: className,
active = false,
elevation = "auto",
children,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
active?: boolean;
/** ✦ overrides the BubbleReactions `elevation` for this chip. */
elevation?: Elevation;
} = $props();
const ctx = getBubbleReactionsCtx();
const isRaised = $derived(
elevation !== "auto" ? elevation === "raised" || elevation === "floating" : (ctx?.raised ?? false)
);
</script>
<button
bind:this={ref}
data-slot="bubble-reaction"
data-active={active ? "" : undefined}
type="button"
aria-pressed={active}
class={cn(bubbleReactionVariants({ active, elevation: isRaised ? "raised" : "flat" }), className)}
{...restProps}
>
{@render children?.()}
</button>
src/lib/registry/ui/bubble/bubble-reactions.svelte
<script lang="ts" module>
import { getContext, setContext } from "svelte";
import { type VariantProps, tv } from "tailwind-variants";
export const bubbleReactionsVariants = tv({
base: "absolute z-10 flex w-fit shrink-0 items-center gap-1",
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
});
export type BubbleReactionsSide = VariantProps<typeof bubbleReactionsVariants>["side"];
export type BubbleReactionsAlign = VariantProps<typeof bubbleReactionsVariants>["align"];
export function setBubbleReactionsCtx(ctx: { raised: boolean }) {
setContext("bubbleReactions", ctx);
}
export function getBubbleReactionsCtx() {
return getContext<{ raised: boolean } | undefined>("bubbleReactions");
}
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
let {
ref = $bindable(null),
class: className,
side = "bottom",
align = "end",
elevation = "auto",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
side?: BubbleReactionsSide;
align?: BubbleReactionsAlign;
/** ✦ depth of the chips: raised +1 / floating +2 bevel every `BubbleReaction` chip. */
elevation?: Elevation;
} = $props();
const level = useElevation(() => elevation, "control");
const raised = $derived(level.current === "raised" || level.current === "floating");
setBubbleReactionsCtx({
get raised() {
return raised;
},
});
</script>
<div
bind:this={ref}
data-slot="bubble-reactions"
data-align={align}
data-side={side}
class={cn(bubbleReactionsVariants({ side, align }), className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/bubble/bubble.svelte
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const bubbleVariants = tv({
base: "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_srgb,var(--primary)_85%,var(--background))]",
secondary:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
muted:
"*:data-[slot=bubble-content]:bg-muted *:data-[slot=bubble-content]:text-muted-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
tinted:
"*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--brand)_25%,var(--popover))] *:data-[slot=bubble-content]:bg-brand-soft *:data-[slot=bubble-content]:text-foreground",
outline:
"*:data-[slot=bubble-content]:border-input *:data-[slot=bubble-content]:bg-transparent [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
// Ghost keeps its vertical padding so avatars line up with the first line.
ghost:
"*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:px-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
destructive:
"*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] *:data-[slot=bubble-content]:bg-destructive-soft *:data-[slot=bubble-content]:text-destructive-text",
},
},
defaultVariants: {
variant: "default",
},
});
export type BubbleVariant = VariantProps<typeof bubbleVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
variant = "default",
align = "start",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: BubbleVariant;
align?: "start" | "end";
} = $props();
</script>
<div
bind:this={ref}
data-slot="bubble"
data-variant={variant}
data-align={align}
class={cn(bubbleVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/bubble/index.ts
import Root, { type BubbleVariant, bubbleVariants } from "./bubble.svelte";
import Content from "./bubble-content.svelte";
import Group from "./bubble-group.svelte";
import Reaction, { bubbleReactionVariants } from "./bubble-reaction.svelte";
import Reactions, { bubbleReactionsVariants } from "./bubble-reactions.svelte";
export {
type BubbleVariant,
bubbleReactionsVariants,
bubbleReactionVariants,
bubbleVariants,
Content,
Content as BubbleContent,
Group,
Group as BubbleGroup,
Reaction,
Reaction as BubbleReaction,
Reactions,
Reactions as BubbleReactions,
Root,
//
Root as Bubble,
};

See the Elevation guide for the levels, roles and layered mode.

Flat by default. elevation on BubbleReactions (raised +1 or floating +2) bevels every BubbleReaction chip; the active tint is kept. A chip can override it with its own elevation.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

default bubble
secondary bubble
muted bubble
tinted bubble
outline bubble
ghost bubble
destructive bubble
Run it and send me the transaction.
The keeper just rebalanced MAG4.

Raised (+1)

default bubble
secondary bubble
muted bubble
tinted bubble
outline bubble
ghost bubble
destructive bubble
Run it and send me the transaction.
The keeper just rebalanced MAG4.

Flat (0)

Flat reactions.
Raised reactions.

Raised (+1)

Flat reactions.
Raised reactions.

Flat (0)

The keeper just rebalanced MAG4.

Raised (+1)

The keeper just rebalanced MAG4.
Part Prop Type Default
Bubble variant default | secondary | muted | tinted | outline | ghost | destructive default
Bubble align start | end start
BubbleContent render ReactElement (button or link bubbles) none
BubbleReactions side top | bottom bottom
BubbleReactions align start | end end
BubbleGroup stacks bubbles div props none
BubbleReactions elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
  • BubbleReaction (active): a solid floating chip with a 1px border and no ring; the active chip is brand-tinted.
  • tinted and destructive use the soft brand and destructive tokens; ghost keeps its vertical padding so avatars line up.
  • Svelte: BubbleContent has no render prop. Pass href to render an <a>, or as="button" for a <button>.