- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner (Toast)
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
Bubble
Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
@Component({
selector: 'spartan-bubble-preview',
imports: [HlmBubbleImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-8 py-12',
},
template: `
<div hlmBubble align="end">
<div hlmBubbleContent>Hey there! what's up?</div>
</div>
<div hlmBubbleGroup>
<div hlmBubble variant="muted">
<div hlmBubbleContent>Hey! Want to see chat bubbles?</div>
</div>
<div hlmBubble variant="muted">
<div hlmBubbleContent>I can group messages, switch sides, and keep the whole thread easy to scan.</div>
<div hlmBubbleReactions role="img" aria-label="Reaction: thumbs up">
<span>👍</span>
</div>
</div>
</div>
<div hlmBubble align="end">
<div hlmBubbleContent>Sure. Hit me with your best demo.</div>
</div>
<div hlmBubble variant="muted">
<div hlmBubbleContent>Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.</div>
<div hlmBubbleReactions role="img" aria-label="Reactions: thumbs up, fire, eyes, and 2 more">
<span>👍</span>
<span>🔥</span>
<span>👀</span>
<span>+2</span>
</div>
</div>
`,
})
export class BubblePreview {}Installation
ng g @spartan-ng/cli:ui bubblenx g @spartan-ng/cli:ui bubbleimport { DestroyRef, ElementRef, HostAttributeToken, Injector, PLATFORM_ID, effect, inject, makeEnvironmentProviders, runInInjectionContext, type EnvironmentProviders } from '@angular/core';
import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay';
import { clsx, type ClassValue } from 'clsx';
import { isPlatformBrowser } from '@angular/common';
import { provideSpartanHlm } from '@spartan-ng/helm/utils';
import { twMerge } from 'tailwind-merge';
export function hlm(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Global map to track class managers per element
const elementClassManagers = new WeakMap<HTMLElement, ElementClassManager>();
// Global mutation observer for all elements
let globalObserver: MutationObserver | null = null;
const observedElements = new Set<HTMLElement>();
interface ElementClassManager {
element: HTMLElement;
sources: Map<number, { classes: Set<string>; order: number }>;
baseClasses: Set<string>;
isUpdating: boolean;
nextOrder: number;
hasInitialized: boolean;
restoreRafId: number | null;
/** Transitions are suppressed until the first effect writes correct classes */
transitionsSuppressed: boolean;
/** Original inline transition value to restore after suppression (empty string = none was set) */
previousTransition: string;
/** Original inline transition priority to preserve !important when restoring */
previousTransitionPriority: string;
}
let sourceCounter = 0;
/**
* This function dynamically adds and removes classes for a given element without requiring
* the a class binding (e.g. `[class]="..."`) which may interfere with other class bindings.
*
* 1. This will merge the existing classes on the element with the new classes.
* 2. It will also remove any classes that were previously added by this function but are no longer present in the new classes.
* 3. Multiple calls to this function on the same element will be merged efficiently.
*/
export function classes(computed: () => ClassValue[] | string, options: ClassesOptions = {}) {
runInInjectionContext(options.injector ?? inject(Injector), () => {
const elementRef = options.elementRef ?? inject(ElementRef);
const platformId = inject(PLATFORM_ID);
const destroyRef = inject(DestroyRef);
const baseClasses = inject(new HostAttributeToken('class'), { optional: true });
const element = elementRef.nativeElement;
// Create unique identifier for this source
const sourceId = sourceCounter++;
// Get or create the class manager for this element
let manager = elementClassManagers.get(element);
if (!manager) {
// Initialize base classes from variation (host attribute 'class')
const initialBaseClasses = new Set<string>();
if (baseClasses) {
toClassList(baseClasses).forEach((cls) => initialBaseClasses.add(cls));
}
manager = {
element,
sources: new Map(),
baseClasses: initialBaseClasses,
isUpdating: false,
nextOrder: 0,
hasInitialized: false,
restoreRafId: null,
transitionsSuppressed: false,
previousTransition: '',
previousTransitionPriority: '',
};
elementClassManagers.set(element, manager);
// Setup global observer if needed and register this element
setupGlobalObserver(platformId);
observedElements.add(element);
// Suppress transitions until the first effect writes correct classes and
// the browser has painted them. This prevents CSS transition animations
// during hydration when classes change from SSR state to client state.
if (isPlatformBrowser(platformId)) {
manager.previousTransition = element.style.getPropertyValue('transition');
manager.previousTransitionPriority = element.style.getPropertyPriority('transition');
element.style.setProperty('transition', 'none', 'important');
manager.transitionsSuppressed = true;
}
}
// Assign order once at registration time
const sourceOrder = manager.nextOrder++;
function updateClasses(): void {
// Get the new classes from the computed function
const newClasses = toClassList(computed());
// Update this source's classes, keeping the original order
manager!.sources.set(sourceId, {
classes: new Set(newClasses),
order: sourceOrder,
});
// Update the element
updateElement(manager!);
// Re-enable transitions after the first effect writes correct classes.
// Deferred to next animation frame so the browser paints the class change
// with transitions disabled first, then re-enables them.
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
manager!.restoreRafId = requestAnimationFrame(() => {
manager!.restoreRafId = null;
restoreTransitionSuppression(manager!);
});
}
}
// Register cleanup with DestroyRef
destroyRef.onDestroy(() => {
if (manager!.restoreRafId !== null) {
cancelAnimationFrame(manager!.restoreRafId);
manager!.restoreRafId = null;
}
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
restoreTransitionSuppression(manager!);
}
// Remove this source from the manager
manager!.sources.delete(sourceId);
// If no more sources, clean up the manager
if (manager!.sources.size === 0) {
cleanupManager(element);
} else {
// Update element without this source's classes
updateElement(manager!);
}
});
/**
* We need this effect to track changes to the computed classes. Ideally, we would use
* afterRenderEffect here, but that doesn't run in SSR contexts, so we use a standard
* effect which works in both browser and SSR.
*/
effect(updateClasses);
});
}
function restoreTransitionSuppression(manager: ElementClassManager): void {
const prev = manager.previousTransition;
if (prev) {
manager.element.style.setProperty('transition', prev, manager.previousTransitionPriority || undefined);
} else {
manager.element.style.removeProperty('transition');
}
}
// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types
function setupGlobalObserver(platformId: Object): void {
if (isPlatformBrowser(platformId) && !globalObserver) {
// Create single global observer that watches the entire document
globalObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const element = mutation.target as HTMLElement;
const manager = elementClassManagers.get(element);
// Only process elements we're managing
if (manager && observedElements.has(element)) {
if (manager.isUpdating) continue; // Ignore changes we're making
// Update base classes to include any externally added classes
const currentClasses = toClassList(element.className);
const allSourceClasses = new Set<string>();
// Collect all classes from all sources
for (const source of manager.sources.values()) {
for (const className of source.classes) {
allSourceClasses.add(className);
}
}
// Any classes not from sources become new base classes
manager.baseClasses.clear();
for (const className of currentClasses) {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
}
updateElement(manager);
}
}
}
});
// Start observing the entire document for class attribute changes
globalObserver.observe(document, {
attributes: true,
attributeFilter: ['class'],
subtree: true, // Watch all descendants
});
}
}
function updateElement(manager: ElementClassManager): void {
if (manager.isUpdating) return; // Prevent recursive updates
manager.isUpdating = true;
// Handle initialization: capture base classes after first source registration
if (!manager.hasInitialized && manager.sources.size > 0) {
// Get current classes on element (may include SSR classes)
const currentClasses = toClassList(manager.element.className);
// Get all classes that will be applied by sources
const allSourceClasses = new Set<string>();
for (const source of manager.sources.values()) {
source.classes.forEach((className) => allSourceClasses.add(className));
}
// Only consider classes as "base" if they're not produced by any source
// This prevents SSR-rendered classes from being preserved as base classes
currentClasses.forEach((className) => {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
});
manager.hasInitialized = true;
}
// Get classes from all sources, sorted by registration order (later takes precedence)
const sortedSources = Array.from(manager.sources.entries()).sort(([, a], [, b]) => a.order - b.order);
const allSourceClasses: string[] = [];
for (const [, source] of sortedSources) {
allSourceClasses.push(...source.classes);
}
// Combine base classes with all source classes, ensuring base classes take precedence
const classesToApply =
allSourceClasses.length > 0 || manager.baseClasses.size > 0
? hlm([...allSourceClasses, ...manager.baseClasses])
: '';
// Apply the classes to the element
if (manager.element.className !== classesToApply) {
manager.element.className = classesToApply;
}
manager.isUpdating = false;
}
function cleanupManager(element: HTMLElement): void {
// Remove from global tracking
observedElements.delete(element);
elementClassManagers.delete(element);
// If no more elements being tracked, cleanup global observer
if (observedElements.size === 0 && globalObserver) {
globalObserver.disconnect();
globalObserver = null;
}
}
interface ClassesOptions {
elementRef?: ElementRef<HTMLElement>;
injector?: Injector;
}
// Cache for parsed class lists to avoid repeated string operations
const classListCache = new Map<string, string[]>();
function toClassList(className: string | ClassValue[]): string[] {
// For simple string inputs, use cache to avoid repeated parsing
if (typeof className === 'string' && classListCache.has(className)) {
return classListCache.get(className)!;
}
const result = clsx(className)
.split(' ')
.filter((c) => c.length > 0);
// Cache string results, but limit cache size to prevent memory growth
if (typeof className === 'string' && classListCache.size < 1000) {
classListCache.set(className, result);
}
return result;
}
/**
* Provides default configuration for Spartan Helm components.
*
* This utility configures the Angular CDK overlay to disable the `usePopover`
* behavior introduced in Angular 21, which causes CDK overlay-based components
* (sheets, dialogs, tooltips, etc.) to render above `position: fixed` elements
* like `<hlm-toaster>`.
*
* @returns {EnvironmentProviders} Environment providers to be added to the application config.
*
* @example
* ```ts
* // app.config.ts
*
*
* export const appConfig: ApplicationConfig = {
* providers: [
* provideSpartanHlm(),
* // ... other providers
* ],
* };
* ```
*/
export function provideSpartanHlm(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: OVERLAY_DEFAULT_CONFIG,
useValue: { usePopover: false },
},
]);
}import { Directive, input } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { cva, type VariantProps } from 'class-variance-authority';
@Directive({
selector: '[hlmBubbleContent],hlm-bubble-content',
host: { 'data-slot': 'bubble-content' },
})
export class HlmBubbleContent {
constructor() {
classes(
() =>
'rounded-xl px-3 py-2 text-sm leading-relaxed [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-ring/50 w-fit max-w-full min-w-0 overflow-hidden border border-transparent wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:ring-3',
);
}
}
@Directive({
selector: '[hlmBubbleGroup],hlm-bubble-group',
host: { 'data-slot': 'bubble-group' },
})
export class HlmBubbleGroup {
constructor() {
classes(() => 'gap-2 flex min-w-0 flex-col');
}
}
const bubbleReactionsVariants = cva(
'bg-muted ring-card gap-1 rounded-full px-1.5 py-0.5 text-xs ring-3 absolute z-10 flex w-fit shrink-0 items-center justify-center has-[button]:p-0',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'start-3',
end: 'end-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
);
export type BubbleReactionsVariants = VariantProps<typeof bubbleReactionsVariants>;
@Directive({
selector: '[hlmBubbleReactions],hlm-bubble-reactions',
host: {
'data-slot': 'bubble-reactions',
'[attr.data-align]': 'align()',
'[attr.data-side]': 'side()',
'[attr.align]': 'null',
},
})
export class HlmBubbleReactions {
public readonly side = input<BubbleReactionsVariants['side']>('bottom');
public readonly align = input<BubbleReactionsVariants['align']>('end');
constructor() {
classes(() => bubbleReactionsVariants({ side: this.side(), align: this.align() }));
}
}
const bubbleVariants = cva(
'max-w-[80%] gap-1 group/bubble relative flex w-fit min-w-0 flex-col 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-primary/80',
secondary: '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-secondary/80',
muted: '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted: '*:data-[slot=bubble-content]:text-foreground *:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline: '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30 *:data-[slot=bubble-content]:border',
ghost: '[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0',
destructive: '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export type BubbleAlign = 'start' | 'end';
@Directive({
selector: '[hlmBubble],hlm-bubble',
host: {
'data-slot': 'bubble',
'[attr.data-variant]': 'variant()',
'[attr.data-align]': 'align()',
// Prevent the legacy HTML `align` attribute (from `align="end"` bindings) from
// forcing text-align on the bubble content.
'[attr.align]': 'null',
},
})
export class HlmBubble {
public readonly variant = input<BubbleVariants['variant']>('default');
public readonly align = input<BubbleAlign>('start');
constructor() {
classes(() => bubbleVariants({ variant: this.variant() }));
}
}
export const HlmBubbleImports = [HlmBubble, HlmBubbleContent, HlmBubbleGroup, HlmBubbleReactions] as const;import { Directive, input } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { cva, type VariantProps } from 'class-variance-authority';
@Directive({
selector: '[hlmBubbleContent],hlm-bubble-content',
host: { 'data-slot': 'bubble-content' },
})
export class HlmBubbleContent {
constructor() {
classes(
() =>
'rounded-xl px-3.5 py-2.5 text-sm leading-relaxed [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-ring/50 w-fit max-w-full min-w-0 overflow-hidden border border-transparent wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:ring-3',
);
}
}
@Directive({
selector: '[hlmBubbleGroup],hlm-bubble-group',
host: { 'data-slot': 'bubble-group' },
})
export class HlmBubbleGroup {
constructor() {
classes(() => 'gap-2.5 flex min-w-0 flex-col');
}
}
const bubbleReactionsVariants = cva(
'bg-muted ring-card gap-1 rounded-full px-2 py-0.5 text-xs shadow-xs ring-3 absolute z-10 flex w-fit shrink-0 items-center justify-center has-[button]:p-0',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'start-3',
end: 'end-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
);
export type BubbleReactionsVariants = VariantProps<typeof bubbleReactionsVariants>;
@Directive({
selector: '[hlmBubbleReactions],hlm-bubble-reactions',
host: {
'data-slot': 'bubble-reactions',
'[attr.data-align]': 'align()',
'[attr.data-side]': 'side()',
'[attr.align]': 'null',
},
})
export class HlmBubbleReactions {
public readonly side = input<BubbleReactionsVariants['side']>('bottom');
public readonly align = input<BubbleReactionsVariants['align']>('end');
constructor() {
classes(() => bubbleReactionsVariants({ side: this.side(), align: this.align() }));
}
}
const bubbleVariants = cva(
'max-w-[80%] gap-1.5 group/bubble relative flex w-fit min-w-0 flex-col 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-primary/80',
secondary: '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
muted: '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted: '*:data-[slot=bubble-content]:text-foreground *:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline: '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30 *:data-[slot=bubble-content]:border *:data-[slot=bubble-content]:shadow-xs',
ghost: '[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0',
destructive: '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export type BubbleAlign = 'start' | 'end';
@Directive({
selector: '[hlmBubble],hlm-bubble',
host: {
'data-slot': 'bubble',
'[attr.data-variant]': 'variant()',
'[attr.data-align]': 'align()',
// Prevent the legacy HTML `align` attribute (from `align="end"` bindings) from
// forcing text-align on the bubble content.
'[attr.align]': 'null',
},
})
export class HlmBubble {
public readonly variant = input<BubbleVariants['variant']>('default');
public readonly align = input<BubbleAlign>('start');
constructor() {
classes(() => bubbleVariants({ variant: this.variant() }));
}
}
export const HlmBubbleImports = [HlmBubble, HlmBubbleContent, HlmBubbleGroup, HlmBubbleReactions] as const;import { Directive, input } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { cva, type VariantProps } from 'class-variance-authority';
@Directive({
selector: '[hlmBubbleContent],hlm-bubble-content',
host: { 'data-slot': 'bubble-content' },
})
export class HlmBubbleContent {
constructor() {
classes(
() =>
'rounded-none px-3 py-2 text-xs leading-relaxed [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-ring/50 w-fit max-w-full min-w-0 overflow-hidden border border-transparent wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:ring-3',
);
}
}
@Directive({
selector: '[hlmBubbleGroup],hlm-bubble-group',
host: { 'data-slot': 'bubble-group' },
})
export class HlmBubbleGroup {
constructor() {
classes(() => 'gap-2 flex min-w-0 flex-col');
}
}
const bubbleReactionsVariants = cva(
'bg-muted ring-card gap-1 rounded-none px-1.5 py-0.5 text-xs ring-1 absolute z-10 flex w-fit shrink-0 items-center justify-center has-[button]:p-0',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'start-3',
end: 'end-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
);
export type BubbleReactionsVariants = VariantProps<typeof bubbleReactionsVariants>;
@Directive({
selector: '[hlmBubbleReactions],hlm-bubble-reactions',
host: {
'data-slot': 'bubble-reactions',
'[attr.data-align]': 'align()',
'[attr.data-side]': 'side()',
'[attr.align]': 'null',
},
})
export class HlmBubbleReactions {
public readonly side = input<BubbleReactionsVariants['side']>('bottom');
public readonly align = input<BubbleReactionsVariants['align']>('end');
constructor() {
classes(() => bubbleReactionsVariants({ side: this.side(), align: this.align() }));
}
}
const bubbleVariants = cva(
'max-w-[80%] gap-1 group/bubble relative flex w-fit min-w-0 flex-col 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-primary/80',
secondary: '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-secondary/80',
muted: '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted: '*:data-[slot=bubble-content]:text-foreground *:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline: '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30 *:data-[slot=bubble-content]:border',
ghost: '[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0',
destructive: '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export type BubbleAlign = 'start' | 'end';
@Directive({
selector: '[hlmBubble],hlm-bubble',
host: {
'data-slot': 'bubble',
'[attr.data-variant]': 'variant()',
'[attr.data-align]': 'align()',
// Prevent the legacy HTML `align` attribute (from `align="end"` bindings) from
// forcing text-align on the bubble content.
'[attr.align]': 'null',
},
})
export class HlmBubble {
public readonly variant = input<BubbleVariants['variant']>('default');
public readonly align = input<BubbleAlign>('start');
constructor() {
classes(() => bubbleVariants({ variant: this.variant() }));
}
}
export const HlmBubbleImports = [HlmBubble, HlmBubbleContent, HlmBubbleGroup, HlmBubbleReactions] as const;import { Directive, input } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { cva, type VariantProps } from 'class-variance-authority';
@Directive({
selector: '[hlmBubbleContent],hlm-bubble-content',
host: { 'data-slot': 'bubble-content' },
})
export class HlmBubbleContent {
constructor() {
classes(
() =>
'rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-ring/50 w-fit max-w-full min-w-0 overflow-hidden border border-transparent wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:ring-3',
);
}
}
@Directive({
selector: '[hlmBubbleGroup],hlm-bubble-group',
host: { 'data-slot': 'bubble-group' },
})
export class HlmBubbleGroup {
constructor() {
classes(() => 'gap-2.5 flex min-w-0 flex-col');
}
}
const bubbleReactionsVariants = cva(
'bg-muted ring-card gap-1 rounded-4xl px-2 py-0.5 text-xs ring-3 absolute z-10 flex w-fit shrink-0 items-center justify-center has-[button]:p-0',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'start-3',
end: 'end-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
);
export type BubbleReactionsVariants = VariantProps<typeof bubbleReactionsVariants>;
@Directive({
selector: '[hlmBubbleReactions],hlm-bubble-reactions',
host: {
'data-slot': 'bubble-reactions',
'[attr.data-align]': 'align()',
'[attr.data-side]': 'side()',
'[attr.align]': 'null',
},
})
export class HlmBubbleReactions {
public readonly side = input<BubbleReactionsVariants['side']>('bottom');
public readonly align = input<BubbleReactionsVariants['align']>('end');
constructor() {
classes(() => bubbleReactionsVariants({ side: this.side(), align: this.align() }));
}
}
const bubbleVariants = cva(
'max-w-[80%] gap-1.5 group/bubble relative flex w-fit min-w-0 flex-col 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-primary/80',
secondary: '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-secondary/80',
muted: '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted: '*:data-[slot=bubble-content]:text-foreground *:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline: '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-input/30 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/50 [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground *:data-[slot=bubble-content]:border',
ghost: '[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0',
destructive: '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export type BubbleAlign = 'start' | 'end';
@Directive({
selector: '[hlmBubble],hlm-bubble',
host: {
'data-slot': 'bubble',
'[attr.data-variant]': 'variant()',
'[attr.data-align]': 'align()',
// Prevent the legacy HTML `align` attribute (from `align="end"` bindings) from
// forcing text-align on the bubble content.
'[attr.align]': 'null',
},
})
export class HlmBubble {
public readonly variant = input<BubbleVariants['variant']>('default');
public readonly align = input<BubbleAlign>('start');
constructor() {
classes(() => bubbleVariants({ variant: this.variant() }));
}
}
export const HlmBubbleImports = [HlmBubble, HlmBubbleContent, HlmBubbleGroup, HlmBubbleReactions] as const;import { Directive, input } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { cva, type VariantProps } from 'class-variance-authority';
@Directive({
selector: '[hlmBubbleContent],hlm-bubble-content',
host: { 'data-slot': 'bubble-content' },
})
export class HlmBubbleContent {
constructor() {
classes(
() =>
'rounded-lg px-2.5 py-1.5 text-xs/relaxed leading-relaxed [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-ring/50 w-fit max-w-full min-w-0 overflow-hidden border border-transparent wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:ring-3',
);
}
}
@Directive({
selector: '[hlmBubbleGroup],hlm-bubble-group',
host: { 'data-slot': 'bubble-group' },
})
export class HlmBubbleGroup {
constructor() {
classes(() => 'gap-1.5 flex min-w-0 flex-col');
}
}
const bubbleReactionsVariants = cva(
'bg-muted ring-card gap-0.5 rounded-full px-1.5 py-0.5 text-[0.625rem] ring-2 absolute z-10 flex w-fit shrink-0 items-center justify-center has-[button]:p-0',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'start-3',
end: 'end-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
);
export type BubbleReactionsVariants = VariantProps<typeof bubbleReactionsVariants>;
@Directive({
selector: '[hlmBubbleReactions],hlm-bubble-reactions',
host: {
'data-slot': 'bubble-reactions',
'[attr.data-align]': 'align()',
'[attr.data-side]': 'side()',
'[attr.align]': 'null',
},
})
export class HlmBubbleReactions {
public readonly side = input<BubbleReactionsVariants['side']>('bottom');
public readonly align = input<BubbleReactionsVariants['align']>('end');
constructor() {
classes(() => bubbleReactionsVariants({ side: this.side(), align: this.align() }));
}
}
const bubbleVariants = cva(
'max-w-[80%] gap-0.5 group/bubble relative flex w-fit min-w-0 flex-col 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-primary/80',
secondary: '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-secondary/80',
muted: '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted: '*:data-[slot=bubble-content]:text-foreground *:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline: '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-input/20 dark:*:data-[slot=bubble-content]:bg-input/30 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/50 [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground *:data-[slot=bubble-content]:border',
ghost: '[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0',
destructive: '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export type BubbleAlign = 'start' | 'end';
@Directive({
selector: '[hlmBubble],hlm-bubble',
host: {
'data-slot': 'bubble',
'[attr.data-variant]': 'variant()',
'[attr.data-align]': 'align()',
// Prevent the legacy HTML `align` attribute (from `align="end"` bindings) from
// forcing text-align on the bubble content.
'[attr.align]': 'null',
},
})
export class HlmBubble {
public readonly variant = input<BubbleVariants['variant']>('default');
public readonly align = input<BubbleAlign>('start');
constructor() {
classes(() => bubbleVariants({ variant: this.variant() }));
}
}
export const HlmBubbleImports = [HlmBubble, HlmBubbleContent, HlmBubbleGroup, HlmBubbleReactions] as const;import { Directive, input } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { cva, type VariantProps } from 'class-variance-authority';
@Directive({
selector: '[hlmBubbleContent],hlm-bubble-content',
host: { 'data-slot': 'bubble-content' },
})
export class HlmBubbleContent {
constructor() {
classes(
() =>
'rounded-4xl px-4 py-2.5 text-sm leading-relaxed [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-ring/50 w-fit max-w-full min-w-0 overflow-hidden border border-transparent wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:ring-3',
);
}
}
@Directive({
selector: '[hlmBubbleGroup],hlm-bubble-group',
host: { 'data-slot': 'bubble-group' },
})
export class HlmBubbleGroup {
constructor() {
classes(() => 'gap-3 flex min-w-0 flex-col');
}
}
const bubbleReactionsVariants = cva(
'bg-muted ring-card gap-1 rounded-3xl px-2 py-0.5 text-xs shadow-sm ring-3 absolute z-10 flex w-fit shrink-0 items-center justify-center has-[button]:p-0',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'start-3',
end: 'end-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
);
export type BubbleReactionsVariants = VariantProps<typeof bubbleReactionsVariants>;
@Directive({
selector: '[hlmBubbleReactions],hlm-bubble-reactions',
host: {
'data-slot': 'bubble-reactions',
'[attr.data-align]': 'align()',
'[attr.data-side]': 'side()',
'[attr.align]': 'null',
},
})
export class HlmBubbleReactions {
public readonly side = input<BubbleReactionsVariants['side']>('bottom');
public readonly align = input<BubbleReactionsVariants['align']>('end');
constructor() {
classes(() => bubbleReactionsVariants({ side: this.side(), align: this.align() }));
}
}
const bubbleVariants = cva(
'max-w-[80%] gap-1.5 group/bubble relative flex w-fit min-w-0 flex-col 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-primary/80',
secondary: '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-secondary/80',
muted: '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted: '*:data-[slot=bubble-content]:text-foreground *:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline: '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30 *:data-[slot=bubble-content]:border dark:*:data-[slot=bubble-content]:bg-transparent',
ghost: '[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0',
destructive: '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export type BubbleAlign = 'start' | 'end';
@Directive({
selector: '[hlmBubble],hlm-bubble',
host: {
'data-slot': 'bubble',
'[attr.data-variant]': 'variant()',
'[attr.data-align]': 'align()',
// Prevent the legacy HTML `align` attribute (from `align="end"` bindings) from
// forcing text-align on the bubble content.
'[attr.align]': 'null',
},
})
export class HlmBubble {
public readonly variant = input<BubbleVariants['variant']>('default');
public readonly align = input<BubbleAlign>('start');
constructor() {
classes(() => bubbleVariants({ variant: this.variant() }));
}
}
export const HlmBubbleImports = [HlmBubble, HlmBubbleContent, HlmBubbleGroup, HlmBubbleReactions] as const;Usage
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';<div hlmBubble>
<div hlmBubbleContent>How can I help you today?</div>
</div> Compose Bubble inside Message for conversation rows. Place avatars, names, timestamps, and message-level actions on Message .
Examples
Variants
Ghost bubbles work for assistant text, markdown , and other content that should not be framed.
This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render code in it.
Ghost bubbles are full width and can take the full width of the container.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
import { hlmCode } from '@spartan-ng/helm/typography';
@Component({
selector: 'spartan-bubble-variants-preview',
imports: [HlmBubbleImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-12 py-12',
},
template: `
<div hlmBubble>
<div hlmBubbleContent>This is the default primary bubble.</div>
</div>
<div hlmBubble variant="secondary" align="end">
<div hlmBubbleContent>This is the secondary variant.</div>
</div>
<div hlmBubble variant="muted">
<div hlmBubbleContent>This one is muted. It uses a lower emphasis color for the chat bubble.</div>
<div hlmBubbleReactions role="img" aria-label="Reaction: thumbs up">
<span>👍</span>
</div>
</div>
<div hlmBubble variant="tinted" align="end">
<div hlmBubbleContent>This one is tinted. The tint is a softer color derived from the primary color.</div>
</div>
<div hlmBubble variant="outline">
<div hlmBubbleContent>We can also use an outlined variant.</div>
</div>
<div hlmBubble variant="destructive" align="end">
<div hlmBubbleContent>Or a destructive variant with a reaction.</div>
<div hlmBubbleReactions role="img" aria-label="Reaction: fire">
<span>🔥</span>
</div>
</div>
<div hlmBubble variant="ghost">
<div hlmBubbleContent class="space-y-4">
<p>
Ghost bubbles work for assistant text,
<strong>markdown</strong>
, and other content that should not be framed.
</p>
<p>
This is perfect for assistant messages that should not have a frame and can take the full width of the
container. You can also render
<code class="${hlmCode}">code</code>
in it.
</p>
<p>Ghost bubbles are full width and can take the full width of the container.</p>
</div>
</div>
`,
})
export class BubbleVariantsPreview {}Alignment
Use align on hlmBubble to align the bubble. For chat UIs, prefer alignment on Message .
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
@Component({
selector: 'spartan-bubble-alignment-preview',
imports: [HlmBubbleImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-8 py-12',
},
template: `
<div hlmBubble variant="muted">
<div hlmBubbleContent>This bubble is aligned to the start. This is the default alignment.</div>
</div>
<div hlmBubble align="end">
<div hlmBubbleContent>This bubble is aligned to the end. Use this for user messages.</div>
</div>
`,
})
export class BubbleAlignmentPreview {}Bubble Group
Use hlmBubbleGroup to group consecutive bubbles from the same sender. Set align on each hlmBubble , not the group.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
@Component({
selector: 'spartan-bubble-group-preview',
imports: [HlmBubbleImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-8 py-12',
},
template: `
<div hlmBubble variant="muted">
<div hlmBubbleContent>Can you tell me what's the issue?</div>
</div>
<div hlmBubbleGroup>
<div hlmBubble align="end">
<div hlmBubbleContent>You tell me!</div>
</div>
<div hlmBubble align="end">
<div hlmBubbleContent>It worked yesterday. You broke it!</div>
</div>
<div hlmBubble align="end">
<div hlmBubbleContent>Find the bug and fix it.</div>
<div hlmBubbleReactions role="img" aria-label="Reactions: eyes" align="start">
<span>👀</span>
</div>
</div>
</div>
<div hlmBubble variant="muted">
<div hlmBubbleContent>Want me to diff yesterday's you against today's you? It's a bit embarrassing.</div>
</div>
`,
})
export class BubbleGroupPreview {}Links and Buttons
Put hlmBubbleContent on a button or a to make the bubble interactive.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
@Component({
selector: 'spartan-bubble-link-button-preview',
imports: [HlmBubbleImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-8 py-12',
},
template: `
<div hlmBubble variant="muted">
<div hlmBubbleContent>How can I help you today?</div>
</div>
<div hlmBubbleGroup>
<div hlmBubble variant="tinted" align="end">
<button hlmBubbleContent type="button" (click)="onForgot()">I forgot my password</button>
</div>
<div hlmBubble variant="tinted" align="end">
<button hlmBubbleContent type="button" (click)="onSubscription()">I need help with my subscription</button>
</div>
<div hlmBubble variant="tinted" align="end">
<button hlmBubbleContent type="button" (click)="onOther()">Something else. Talk to a human.</button>
</div>
</div>
`,
})
export class BubbleLinkButtonPreview {
protected onForgot(): void {
toast('You clicked forgot password');
}
protected onSubscription(): void {
toast('You clicked help with subscription');
}
protected onOther(): void {
toast('You clicked something else. Talk to a human.');
}
}Reactions
Use hlmBubbleReactions for emoji reactions or quick actions. Reactions overlap the bubble edge, so leave vertical space between rows.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
import { HlmButton } from '@spartan-ng/helm/button';
@Component({
selector: 'spartan-bubble-reactions-preview',
imports: [HlmBubbleImports, HlmButton],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-12 py-12',
},
template: `
<div hlmBubble variant="muted" align="end">
<div hlmBubbleContent>I don't need tests, I know my code works.</div>
<div hlmBubbleReactions align="start" role="img" aria-label="Reactions: thumbs up, surprised">
<span>👍</span>
<span>😮</span>
</div>
</div>
<div hlmBubble variant="muted">
<div hlmBubbleContent>Bold. Fine I'll add some tests. I'll let you know when they're done.</div>
<div hlmBubbleReactions role="img" aria-label="Reactions: eyes, rocket, and 2 more">
<span>👀</span>
<span>🚀</span>
<span>+2</span>
</div>
</div>
<div hlmBubble variant="default" align="end">
<div hlmBubbleContent>Tests passed on the first try. All 142 of them. Looking good!</div>
<div hlmBubbleReactions side="top" align="start" role="img" aria-label="Reactions: party popper, clapping hands">
<span>🎉</span>
<span>👏</span>
</div>
</div>
<div hlmBubble variant="destructive">
<div hlmBubbleContent>Are you sure I can run this command?</div>
<div hlmBubbleReactions>
<button hlmBtn variant="ghost" size="xs" (click)="onYes()">Yes, run it</button>
</div>
</div>
`,
})
export class BubbleReactionsPreview {
protected onYes(): void {
toast.success('You clicked yes, running command...');
}
}Show More / Collapsible
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown } from '@ng-icons/lucide';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
import { HlmButton } from '@spartan-ng/helm/button';
const text = `The accessibility review found two focus states that were visually too subtle in dark mode.
I checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface.
The dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.
I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.`;
const previewLength = 180;
@Component({
selector: 'spartan-bubble-collapsible-preview',
imports: [HlmBubbleImports, HlmButton, NgIcon],
providers: [provideIcons({ lucideChevronDown })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-8 py-12',
},
template: `
<div hlmBubble variant="muted">
<div hlmBubbleContent>How can I help you today?</div>
</div>
<div hlmBubble variant="muted" align="end">
<div hlmBubbleContent class="whitespace-pre-line">
<div>{{ _open() || !_isLong ? _text : _preview }}</div>
@if (_isLong) {
<button
hlmBtn
variant="link"
class="text-muted-foreground gap-1 p-0"
[attr.aria-expanded]="_open()"
(click)="_open.set(!_open())"
>
{{ _open() ? 'Show less' : 'Show more' }}
<ng-icon name="lucideChevronDown" class="transition-transform" [class.rotate-180]="_open()" />
</button>
}
</div>
</div>
`,
})
export class BubbleCollapsiblePreview {
protected readonly _open = signal(false);
protected readonly _text = text;
protected readonly _isLong = text.length > previewLength;
protected readonly _preview = `${text.slice(0, previewLength)}...`;
}Tooltip
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideCheck } from '@ng-icons/lucide';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
import { HlmButton } from '@spartan-ng/helm/button';
import { HlmTooltipImports } from '@spartan-ng/helm/tooltip';
@Component({
selector: 'spartan-bubble-tooltip-preview',
imports: [HlmBubbleImports, HlmTooltipImports, HlmButton, NgIcon],
providers: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-4 py-12',
},
template: `
<div hlmBubble variant="secondary">
<div hlmBubbleContent>Did you remove the stale route?</div>
</div>
<div hlmBubble align="end">
<div hlmBubbleContent>Yes, removed it from the registry.</div>
<div hlmBubbleReactions>
<button hlmTooltip="Read on Jan 5, 2026 at 4:32 PM" hlmBtn variant="ghost" size="icon-xs">
<ng-icon name="lucideCheck" />
</button>
</div>
</div>
`,
})
export class BubbleTooltipPreview {}Popover
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideInfo } from '@ng-icons/lucide';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
import { HlmButton } from '@spartan-ng/helm/button';
import { HlmPopoverImports } from '@spartan-ng/helm/popover';
@Component({
selector: 'spartan-bubble-popover-preview',
imports: [HlmBubbleImports, HlmPopoverImports, HlmButton, NgIcon],
providers: [provideIcons({ lucideInfo })],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full max-w-sm flex-col gap-4 py-12',
},
template: `
<div hlmBubble align="end">
<div hlmBubbleContent>Run the build script.</div>
</div>
<div hlmBubble variant="destructive">
<div hlmBubbleContent>Failed to run the command.</div>
<div hlmBubbleReactions>
<hlm-popover sideOffset="5">
<button
hlmPopoverTrigger
hlmBtn
variant="ghost"
size="icon-xs"
aria-label="Show error details"
class="aria-expanded:text-destructive"
>
<ng-icon name="lucideInfo" />
</button>
<hlm-popover-content *hlmPopoverPortal="let ctx" class="w-72">
<hlm-popover-header>
<div hlmPopoverTitle>Command failed with exit code 1</div>
<p hlmPopoverDescription>ENOENT: no such file or directory, open pnpm-lock.yaml</p>
</hlm-popover-header>
</hlm-popover-content>
</hlm-popover>
</div>
</div>
`,
})
export class BubblePopoverPreview {}RTL
To enable RTL support in spartan-ng, see the RTL configuration guide.
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { TranslateService, Translations } from '@spartan-ng/app/app/shared/translate.service';
import { HlmBubbleImports } from '@spartan-ng/helm/bubble';
@Component({
selector: 'spartan-bubble-rtl-preview',
imports: [HlmBubbleImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'theme-blue flex w-full max-w-sm flex-col gap-8',
},
template: `
<div class="flex w-full flex-col gap-8" [dir]="_dir()">
<div hlmBubble variant="muted">
<div hlmBubbleContent>{{ _t()['start'] }}</div>
</div>
<div hlmBubble align="end">
<div hlmBubbleContent>{{ _t()['end'] }}</div>
<div hlmBubbleReactions role="img" [attr.aria-label]="_t()['reactionLabel']">
<span>👍</span>
</div>
</div>
</div>
`,
})
export class BubbleRtlPreview {
private readonly _language = inject(TranslateService).language;
private readonly _translations: Translations = {
en: {
dir: 'ltr',
values: {
start: 'This bubble is aligned to the start. This is the default alignment.',
end: 'This bubble is aligned to the end. Use this for user messages.',
reactionLabel: 'Reaction: thumbs up',
},
},
ar: {
dir: 'rtl',
values: {
start: 'هذه الفقاعة محاذاة إلى البداية. هذا هو المحاذاة الافتراضية.',
end: 'هذه الفقاعة محاذاة إلى النهاية. استخدم هذا لرسائل المستخدم.',
reactionLabel: 'تفاعل: إبهام لأعلى',
},
},
he: {
dir: 'rtl',
values: {
start: 'הבועה הזו מיושרת להתחלה. זוהי היישור ברירת המחדל.',
end: 'הבועה הזו מיושרת לסוף. השתמש בזה להודעות משתמש.',
reactionLabel: 'תגובה: אגודל למעלה',
},
},
};
private readonly _translation = computed(() => this._translations[this._language()]);
protected readonly _t = computed(() => this._translation().values);
protected readonly _dir = computed(() => this._translation().dir);
}Helm API
HlmBubbleContent
Selector: [hlmBubbleContent],hlm-bubble-content
HlmBubbleGroup
Selector: [hlmBubbleGroup],hlm-bubble-group
HlmBubbleReactions
Selector: [hlmBubbleReactions],hlm-bubble-reactions
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| side | BubbleReactionsVariants['side'] | 'bottom' | - |
| align | BubbleReactionsVariants['align'] | 'end' | - |
HlmBubble
Selector: [hlmBubble],hlm-bubble
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | BubbleVariants['variant'] | 'default' | - |
| align | BubbleAlign | 'start' | - |
On This Page