- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Icon
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Label
- Menubar
- 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
Card
Displays a card with header, content, and footer.
Login to your account
Enter your email below to login to your account
import { Component } from '@angular/core';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-card-preview',
imports: [HlmCardImports, HlmLabelImports, HlmInputImports, HlmButtonImports],
host: { class: 'w-full max-w-md' },
template: `
<hlm-card class="w-full max-w-sm">
<hlm-card-header>
<h3 hlmCardTitle>Login to your account</h3>
<p hlmCardDescription>Enter your email below to login to your account</p>
<div hlmCardAction>
<button hlmBtn variant="link">Sign Up</button>
</div>
</hlm-card-header>
<div hlmCardContent>
<form id="login-form">
<div class="flex flex-col gap-6">
<div class="grid gap-2">
<label hlmLabel for="email">Email</label>
<input type="email" id="email" placeholder="m@example.com" required hlmInput />
</div>
<div class="grid gap-2">
<div class="flex items-center">
<label hlmLabel for="password">Password</label>
<a href="#" class="ml-auto inline-block text-sm underline-offset-4 hover:underline">
Forgot your password?
</a>
</div>
<input type="password" id="password" hlmInput />
</div>
</div>
</form>
</div>
<hlm-card-footer class="flex-col gap-2">
<button hlmBtn type="submit" class="w-full" form="login-form">Login</button>
<button hlmBtn variant="outline" class="w-full">Login with Google</button>
</hlm-card-footer>
</hlm-card>
`,
})
export class CardPreview {}Installation
ng g @spartan-ng/cli:ui cardnx g @spartan-ng/cli:ui cardimport { DestroyRef, ElementRef, HostAttributeToken, Injector, PLATFORM_ID, effect, inject, runInInjectionContext } from '@angular/core';
import { clsx, type ClassValue } from 'clsx';
import { isPlatformBrowser } from '@angular/common';
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;
}import { Directive, InjectionToken, inject, input, type ValueProvider } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
@Directive({
selector: '[hlmCardAction]',
host: { 'data-slot': 'card-action' },
})
export class HlmCardAction {
constructor() {
classes(() => 'col-start-2 row-span-2 row-start-1 self-start justify-self-end');
}
}
@Directive({
selector: '[hlmCardContent]',
host: { 'data-slot': 'card-content' },
})
export class HlmCardContent {
constructor() {
classes(() => 'px-(--card-spacing)');
}
}
@Directive({
selector: '[hlmCardDescription]',
host: { 'data-slot': 'card-description' },
})
export class HlmCardDescription {
constructor() {
classes(() => 'text-muted-foreground text-sm');
}
}
@Directive({
selector: '[hlmCardFooter],hlm-card-footer',
host: { 'data-slot': 'card-footer' },
})
export class HlmCardFooter {
constructor() {
classes(() => 'rounded-b-xl px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center');
}
}
@Directive({
selector: '[hlmCardHeader],hlm-card-header',
host: { 'data-slot': 'card-header' },
})
export class HlmCardHeader {
constructor() {
classes(
() =>
'gap-1 rounded-t-xl px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
);
}
}
@Directive({
selector: '[hlmCardTitle]',
host: { 'data-slot': 'card-title' },
})
export class HlmCardTitle {
constructor() {
classes(() => 'text-base leading-normal font-medium group-data-[size=sm]/card:text-sm');
}
}
export type HlmCardConfig = {
size: 'sm' | 'default';
};
const defaultConfig: HlmCardConfig = {
size: 'default',
};
const HlmCardConfigToken = new InjectionToken<HlmCardConfig>('HlmCardConfig');
export function provideHlmCardConfig(config: Partial<HlmCardConfig>): ValueProvider {
return { provide: HlmCardConfigToken, useValue: { ...defaultConfig, ...config } };
}
export function injectHlmCardConfig(): HlmCardConfig {
return inject(HlmCardConfigToken, { optional: true }) ?? defaultConfig;
}
@Directive({
selector: '[hlmCard],hlm-card',
host: {
'data-slot': 'card',
'[attr.data-size]': 'size()',
},
})
export class HlmCard {
private readonly _defaultConfig = injectHlmCardConfig();
public readonly size = input<HlmCardConfig['size']>(this._defaultConfig.size);
constructor() {
classes(() => 'ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) overflow-hidden rounded-xl py-(--card-spacing) text-sm shadow-xs ring-1 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col');
}
}
export const HlmCardImports = [
HlmCard,
HlmCardAction,
HlmCardContent,
HlmCardDescription,
HlmCardFooter,
HlmCardHeader,
HlmCardTitle,
] as const;import { Directive, InjectionToken, inject, input, type ValueProvider } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
@Directive({
selector: '[hlmCardAction]',
host: { 'data-slot': 'card-action' },
})
export class HlmCardAction {
constructor() {
classes(() => 'col-start-2 row-span-2 row-start-1 self-start justify-self-end');
}
}
@Directive({
selector: '[hlmCardContent]',
host: { 'data-slot': 'card-content' },
})
export class HlmCardContent {
constructor() {
classes(() => 'px-(--card-spacing)');
}
}
@Directive({
selector: '[hlmCardDescription]',
host: { 'data-slot': 'card-description' },
})
export class HlmCardDescription {
constructor() {
classes(() => 'text-muted-foreground text-xs/relaxed');
}
}
@Directive({
selector: '[hlmCardFooter],hlm-card-footer',
host: { 'data-slot': 'card-footer' },
})
export class HlmCardFooter {
constructor() {
classes(() => 'rounded-none border-t p-(--card-spacing) flex items-center');
}
}
@Directive({
selector: '[hlmCardHeader],hlm-card-header',
host: { 'data-slot': 'card-header' },
})
export class HlmCardHeader {
constructor() {
classes(
() =>
'gap-1 rounded-none px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
);
}
}
@Directive({
selector: '[hlmCardTitle]',
host: { 'data-slot': 'card-title' },
})
export class HlmCardTitle {
constructor() {
classes(() => 'text-sm font-medium group-data-[size=sm]/card:text-sm');
}
}
export type HlmCardConfig = {
size: 'sm' | 'default';
};
const defaultConfig: HlmCardConfig = {
size: 'default',
};
const HlmCardConfigToken = new InjectionToken<HlmCardConfig>('HlmCardConfig');
export function provideHlmCardConfig(config: Partial<HlmCardConfig>): ValueProvider {
return { provide: HlmCardConfigToken, useValue: { ...defaultConfig, ...config } };
}
export function injectHlmCardConfig(): HlmCardConfig {
return inject(HlmCardConfigToken, { optional: true }) ?? defaultConfig;
}
@Directive({
selector: '[hlmCard],hlm-card',
host: {
'data-slot': 'card',
'[attr.data-size]': 'size()',
},
})
export class HlmCard {
private readonly _defaultConfig = injectHlmCardConfig();
public readonly size = input<HlmCardConfig['size']>(this._defaultConfig.size);
constructor() {
classes(() => 'ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) overflow-hidden rounded-none py-(--card-spacing) text-xs/relaxed ring-1 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none group/card flex flex-col');
}
}
export const HlmCardImports = [
HlmCard,
HlmCardAction,
HlmCardContent,
HlmCardDescription,
HlmCardFooter,
HlmCardHeader,
HlmCardTitle,
] as const;import { Directive, InjectionToken, inject, input, type ValueProvider } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
@Directive({
selector: '[hlmCardAction]',
host: { 'data-slot': 'card-action' },
})
export class HlmCardAction {
constructor() {
classes(() => 'col-start-2 row-span-2 row-start-1 self-start justify-self-end');
}
}
@Directive({
selector: '[hlmCardContent]',
host: { 'data-slot': 'card-content' },
})
export class HlmCardContent {
constructor() {
classes(() => 'px-(--card-spacing)');
}
}
@Directive({
selector: '[hlmCardDescription]',
host: { 'data-slot': 'card-description' },
})
export class HlmCardDescription {
constructor() {
classes(() => 'text-muted-foreground text-sm');
}
}
@Directive({
selector: '[hlmCardFooter],hlm-card-footer',
host: { 'data-slot': 'card-footer' },
})
export class HlmCardFooter {
constructor() {
classes(() => 'rounded-b-xl px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center');
}
}
@Directive({
selector: '[hlmCardHeader],hlm-card-header',
host: { 'data-slot': 'card-header' },
})
export class HlmCardHeader {
constructor() {
classes(
() =>
'gap-2 rounded-t-xl px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
);
}
}
@Directive({
selector: '[hlmCardTitle]',
host: { 'data-slot': 'card-title' },
})
export class HlmCardTitle {
constructor() {
classes(() => 'text-base font-medium');
}
}
export type HlmCardConfig = {
size: 'sm' | 'default';
};
const defaultConfig: HlmCardConfig = {
size: 'default',
};
const HlmCardConfigToken = new InjectionToken<HlmCardConfig>('HlmCardConfig');
export function provideHlmCardConfig(config: Partial<HlmCardConfig>): ValueProvider {
return { provide: HlmCardConfigToken, useValue: { ...defaultConfig, ...config } };
}
export function injectHlmCardConfig(): HlmCardConfig {
return inject(HlmCardConfigToken, { optional: true }) ?? defaultConfig;
}
@Directive({
selector: '[hlmCard],hlm-card',
host: {
'data-slot': 'card',
'[attr.data-size]': 'size()',
},
})
export class HlmCard {
private readonly _defaultConfig = injectHlmCardConfig();
public readonly size = input<HlmCardConfig['size']>(this._defaultConfig.size);
constructor() {
classes(() => 'ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) overflow-hidden rounded-2xl py-(--card-spacing) text-sm ring-1 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col');
}
}
export const HlmCardImports = [
HlmCard,
HlmCardAction,
HlmCardContent,
HlmCardDescription,
HlmCardFooter,
HlmCardHeader,
HlmCardTitle,
] as const;import { Directive, InjectionToken, inject, input, type ValueProvider } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
@Directive({
selector: '[hlmCardAction]',
host: { 'data-slot': 'card-action' },
})
export class HlmCardAction {
constructor() {
classes(() => 'col-start-2 row-span-2 row-start-1 self-start justify-self-end');
}
}
@Directive({
selector: '[hlmCardContent]',
host: { 'data-slot': 'card-content' },
})
export class HlmCardContent {
constructor() {
classes(() => 'px-(--card-spacing)');
}
}
@Directive({
selector: '[hlmCardDescription]',
host: { 'data-slot': 'card-description' },
})
export class HlmCardDescription {
constructor() {
classes(() => 'text-muted-foreground text-xs/relaxed');
}
}
@Directive({
selector: '[hlmCardFooter],hlm-card-footer',
host: { 'data-slot': 'card-footer' },
})
export class HlmCardFooter {
constructor() {
classes(() => 'rounded-b-lg px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center');
}
}
@Directive({
selector: '[hlmCardHeader],hlm-card-header',
host: { 'data-slot': 'card-header' },
})
export class HlmCardHeader {
constructor() {
classes(
() =>
'gap-1 rounded-t-lg px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
);
}
}
@Directive({
selector: '[hlmCardTitle]',
host: { 'data-slot': 'card-title' },
})
export class HlmCardTitle {
constructor() {
classes(() => 'text-sm font-medium');
}
}
export type HlmCardConfig = {
size: 'sm' | 'default';
};
const defaultConfig: HlmCardConfig = {
size: 'default',
};
const HlmCardConfigToken = new InjectionToken<HlmCardConfig>('HlmCardConfig');
export function provideHlmCardConfig(config: Partial<HlmCardConfig>): ValueProvider {
return { provide: HlmCardConfigToken, useValue: { ...defaultConfig, ...config } };
}
export function injectHlmCardConfig(): HlmCardConfig {
return inject(HlmCardConfigToken, { optional: true }) ?? defaultConfig;
}
@Directive({
selector: '[hlmCard],hlm-card',
host: {
'data-slot': 'card',
'[attr.data-size]': 'size()',
},
})
export class HlmCard {
private readonly _defaultConfig = injectHlmCardConfig();
public readonly size = input<HlmCardConfig['size']>(this._defaultConfig.size);
constructor() {
classes(() => 'ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) overflow-hidden rounded-lg py-(--card-spacing) text-xs/relaxed ring-1 [--card-spacing:--spacing(4)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg group/card flex flex-col');
}
}
export const HlmCardImports = [
HlmCard,
HlmCardAction,
HlmCardContent,
HlmCardDescription,
HlmCardFooter,
HlmCardHeader,
HlmCardTitle,
] as const;import { Directive, InjectionToken, inject, input, type ValueProvider } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
@Directive({
selector: '[hlmCardAction]',
host: { 'data-slot': 'card-action' },
})
export class HlmCardAction {
constructor() {
classes(() => 'col-start-2 row-span-2 row-start-1 self-start justify-self-end');
}
}
@Directive({
selector: '[hlmCardContent]',
host: { 'data-slot': 'card-content' },
})
export class HlmCardContent {
constructor() {
classes(() => 'px-(--card-spacing)');
}
}
@Directive({
selector: '[hlmCardDescription]',
host: { 'data-slot': 'card-description' },
})
export class HlmCardDescription {
constructor() {
classes(() => 'text-muted-foreground text-sm');
}
}
@Directive({
selector: '[hlmCardFooter],hlm-card-footer',
host: { 'data-slot': 'card-footer' },
})
export class HlmCardFooter {
constructor() {
classes(() => 'bg-muted/50 rounded-b-xl border-t p-(--card-spacing) flex items-center');
}
}
@Directive({
selector: '[hlmCardHeader],hlm-card-header',
host: { 'data-slot': 'card-header' },
})
export class HlmCardHeader {
constructor() {
classes(
() =>
'gap-1 rounded-t-xl px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
);
}
}
@Directive({
selector: '[hlmCardTitle]',
host: { 'data-slot': 'card-title' },
})
export class HlmCardTitle {
constructor() {
classes(() => 'text-base leading-snug font-medium group-data-[size=sm]/card:text-sm');
}
}
export type HlmCardConfig = {
size: 'sm' | 'default';
};
const defaultConfig: HlmCardConfig = {
size: 'default',
};
const HlmCardConfigToken = new InjectionToken<HlmCardConfig>('HlmCardConfig');
export function provideHlmCardConfig(config: Partial<HlmCardConfig>): ValueProvider {
return { provide: HlmCardConfigToken, useValue: { ...defaultConfig, ...config } };
}
export function injectHlmCardConfig(): HlmCardConfig {
return inject(HlmCardConfigToken, { optional: true }) ?? defaultConfig;
}
@Directive({
selector: '[hlmCard],hlm-card',
host: {
'data-slot': 'card',
'[attr.data-size]': 'size()',
},
})
export class HlmCard {
private readonly _defaultConfig = injectHlmCardConfig();
public readonly size = input<HlmCardConfig['size']>(this._defaultConfig.size);
constructor() {
classes(() => 'ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) overflow-hidden rounded-xl py-(--card-spacing) text-sm ring-1 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col');
}
}
export const HlmCardImports = [
HlmCard,
HlmCardAction,
HlmCardContent,
HlmCardDescription,
HlmCardFooter,
HlmCardHeader,
HlmCardTitle,
] as const;import { Directive, InjectionToken, inject, input, type ValueProvider } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
@Directive({
selector: '[hlmCardAction]',
host: { 'data-slot': 'card-action' },
})
export class HlmCardAction {
constructor() {
classes(() => 'col-start-2 row-span-2 row-start-1 self-start justify-self-end');
}
}
@Directive({
selector: '[hlmCardContent]',
host: { 'data-slot': 'card-content' },
})
export class HlmCardContent {
constructor() {
classes(() => 'px-(--card-spacing)');
}
}
@Directive({
selector: '[hlmCardDescription]',
host: { 'data-slot': 'card-description' },
})
export class HlmCardDescription {
constructor() {
classes(() => 'text-muted-foreground text-sm');
}
}
@Directive({
selector: '[hlmCardFooter],hlm-card-footer',
host: { 'data-slot': 'card-footer' },
})
export class HlmCardFooter {
constructor() {
classes(() => 'rounded-b-4xl px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center');
}
}
@Directive({
selector: '[hlmCardHeader],hlm-card-header',
host: { 'data-slot': 'card-header' },
})
export class HlmCardHeader {
constructor() {
classes(
() =>
'gap-1.5 rounded-t-4xl px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]',
);
}
}
@Directive({
selector: '[hlmCardTitle]',
host: { 'data-slot': 'card-title' },
})
export class HlmCardTitle {
constructor() {
classes(() => 'text-base font-medium');
}
}
export type HlmCardConfig = {
size: 'sm' | 'default';
};
const defaultConfig: HlmCardConfig = {
size: 'default',
};
const HlmCardConfigToken = new InjectionToken<HlmCardConfig>('HlmCardConfig');
export function provideHlmCardConfig(config: Partial<HlmCardConfig>): ValueProvider {
return { provide: HlmCardConfigToken, useValue: { ...defaultConfig, ...config } };
}
export function injectHlmCardConfig(): HlmCardConfig {
return inject(HlmCardConfigToken, { optional: true }) ?? defaultConfig;
}
@Directive({
selector: '[hlmCard],hlm-card',
host: {
'data-slot': 'card',
'[attr.data-size]': 'size()',
},
})
export class HlmCard {
private readonly _defaultConfig = injectHlmCardConfig();
public readonly size = input<HlmCardConfig['size']>(this._defaultConfig.size);
constructor() {
classes(() => 'bg-card text-card-foreground ring-foreground/5 dark:ring-foreground/10 gap-(--card-spacing) overflow-hidden rounded-4xl py-(--card-spacing) text-sm shadow-md ring-1 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-4xl *:[img:last-child]:rounded-b-4xl group/card flex flex-col');
}
}
export const HlmCardImports = [
HlmCard,
HlmCardAction,
HlmCardContent,
HlmCardDescription,
HlmCardFooter,
HlmCardHeader,
HlmCardTitle,
] as const;Usage
import { HlmCardImports } from '@spartan-ng/helm/card';<hlm-card>
<hlm-card-header>
<h3 hlmCardTitle>Card Title</h3>
<p hlmCardDescription>Card Description</p>
<div hlmCardAction>Card Action</div>
</hlm-card-header>
<div hlmCardContent>Card Content</div>
<hlm-card-footer>Card Footer</hlm-card-footer>
</hlm-card>Examples
Size
Use the size="sm" prop to set the size of the card to small. The small size variant uses smaller spacing.
Small Card
This card uses the small size variant.
The card component supports a size prop that can be set to "sm" for a more compact appearance.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
@Component({
selector: 'spartan-card-size-preview',
imports: [HlmCardImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-card size="sm" class="mx-auto w-full max-w-sm">
<hlm-card-header>
<h3 hlmCardTitle>Small Card</h3>
<p hlmCardDescription>This card uses the small size variant.</p>
</hlm-card-header>
<div hlmCardContent>
<p>The card component supports a size prop that can be set to "sm" for a more compact appearance.</p>
</div>
<hlm-card-footer>
<button hlmBtn variant="outline" size="sm" class="w-full">Action</button>
</hlm-card-footer>
</hlm-card>
`,
})
export class CardSizePreview {}Spacing
In addition to the size prop, you can use the --card-spacing CSS variable to control the spacing between sections and the inset of card parts.
Login to your account
Enter your email below to login to your account
import { Component, computed, signal } from '@angular/core';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmLabelImports } from '@spartan-ng/helm/label';
import { HlmToggleGroupImports } from '@spartan-ng/helm/toggle-group';
@Component({
selector: 'spartan-card-spacing',
imports: [HlmCardImports, HlmLabelImports, HlmInputImports, HlmButtonImports, HlmToggleGroupImports],
host: { class: 'w-full max-w-md grid gap-4' },
template: `
<hlm-toggle-group type="single" size="sm" variant="outline" spacing="2" [(value)]="spacing">
@for (space of spacingOptions; track $index) {
<button hlmToggleGroupItem [value]="space.value" [aria-label]="'Toggle ' + space.label">
{{ space.label }}
</button>
}
</hlm-toggle-group>
<hlm-card class="w-full max-w-sm" [class]="spacingClass()">
<hlm-card-header>
<h3 hlmCardTitle>Login to your account</h3>
<p hlmCardDescription>Enter your email below to login to your account</p>
<div hlmCardAction>
<button hlmBtn variant="link">Sign Up</button>
</div>
</hlm-card-header>
<div hlmCardContent>
<form id="login-form">
<div class="flex flex-col gap-6">
<div class="grid gap-2">
<label hlmLabel for="email">Email</label>
<input type="email" id="email" placeholder="m@example.com" required hlmInput />
</div>
<div class="grid gap-2">
<div class="flex items-center">
<label hlmLabel for="password">Password</label>
<a href="#" class="ml-auto inline-block text-sm underline-offset-4 hover:underline">
Forgot your password?
</a>
</div>
<input type="password" id="password" hlmInput />
</div>
</div>
</form>
</div>
<hlm-card-footer class="flex-col gap-2">
<button hlmBtn type="submit" class="w-full" form="login-form">Login</button>
<button hlmBtn variant="outline" class="w-full">Login with Google</button>
</hlm-card-footer>
</hlm-card>
`,
})
export class CardSpacing {
public readonly spacing = signal('4');
public readonly spacingOptions = [
{
className: '[--card-spacing:--spacing(4)]',
label: '16px',
value: '4',
},
{
className: '[--card-spacing:--spacing(5)]',
label: '20px',
value: '5',
},
{
className: '[--card-spacing:--spacing(6)]',
label: '24px',
value: '6',
},
{
className: '[--card-spacing:--spacing(8)]',
label: '32px',
value: '8',
},
];
public readonly spacingClass = computed(
() => this.spacingOptions.find((option) => option.value === this.spacing())?.className,
);
} Use negative margins with -mx-(--card-spacing) to make content go edge to edge while keeping it aligned with the card inset. When the edge-to-edge content sits above a footer, use -mb-(--card-spacing) on hlmCardContent to remove the section gap.
Terms of Service
Review the terms before accepting the agreement.
These terms govern your use of the workspace, including access to shared documents, project files, and collaboration tools.
You are responsible for the content you upload and for ensuring that your team has the appropriate permissions to view or edit it.
We may update features or limits as the service evolves. When those changes materially affect your workflow, we will notify your workspace administrators.
By continuing, you agree to keep your account credentials secure and to follow your organization's acceptable use policies.
import { Component } from '@angular/core';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-card-edge-to-edge',
imports: [HlmCardImports, HlmLabelImports, HlmInputImports, HlmButtonImports],
host: { class: 'w-full max-w-md' },
template: `
<hlm-card class="mx-auto w-full max-w-sm">
<hlm-card-header>
<h3 hlmCardTitle>Terms of Service</h3>
<p hlmCardDescription>Review the terms before accepting the agreement.</p>
</hlm-card-header>
<div hlmCardContent class="-mb-(--card-spacing)">
<div
class="bg-muted/50 -mx-(--card-spacing) max-h-48 space-y-4 overflow-y-scroll border-t px-(--card-spacing) py-4 text-sm leading-relaxed"
>
<p>
These terms govern your use of the workspace, including access to shared documents, project files, and
collaboration tools.
</p>
<p>
You are responsible for the content you upload and for ensuring that your team has the appropriate
permissions to view or edit it.
</p>
<p>
We may update features or limits as the service evolves. When those changes materially affect your workflow,
we will notify your workspace administrators.
</p>
<p>
By continuing, you agree to keep your account credentials secure and to follow your organization's
acceptable use policies.
</p>
</div>
</div>
<hlm-card-footer class="justify-end gap-2 pt-(--card-spacing)">
<button hlmBtn variant="outline">Decline</button>
<button hlmBtn>Accept</button>
</hlm-card-footer>
</hlm-card>
`,
})
export class CardEdgeToEdge {}Image
Design systems meetup
A practical talk on component APIs, accessibility, and shipping faster.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { HlmBadgeImports } from '@spartan-ng/helm/badge';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
@Component({
selector: 'spartan-card-image-preview',
imports: [HlmCardImports, HlmButtonImports, HlmBadgeImports],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<hlm-card class="relative mx-auto w-full max-w-sm pt-0">
<div class="bg-primary absolute inset-0 z-30 aspect-video opacity-50 mix-blend-color"></div>
<img
src="https://images.unsplash.com/photo-1604076850742-4c7221f3101b?q=80&w=1887&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt="Event cover"
class="relative z-20 aspect-video w-full object-cover brightness-60 grayscale dark:brightness-40"
/>
<hlm-card-header>
<div hlmCardAction>
<span hlmBadge variant="secondary">Featured</span>
</div>
<h3 hlmCardTitle>Design systems meetup</h3>
<p hlmCardDescription>A practical talk on component APIs, accessibility, and shipping faster.</p>
</hlm-card-header>
<hlm-card-footer>
<button hlmBtn class="w-full">View Event</button>
</hlm-card-footer>
</hlm-card>
`,
})
export class CardImagePreview {}RTL
To enable RTL support in spartan-ng, see the RTL configuration guide.
تسجيل الدخول إلى حسابك
أدخل بريدك الإلكتروني أدناه لتسجيل الدخول إلى حسابك
import { Component, computed, inject } from '@angular/core';
import { TranslateService, Translations } from '@spartan-ng/app/app/shared/translate.service';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmLabelImports } from '@spartan-ng/helm/label';
@Component({
selector: 'spartan-card-rtl',
imports: [HlmCardImports, HlmLabelImports, HlmInputImports, HlmButtonImports],
host: { class: 'w-full max-w-md' },
template: `
<hlm-card class="w-full max-w-sm" [dir]="_dir()">
<hlm-card-header>
<h3 hlmCardTitle>{{ _t()['title'] }}</h3>
<p hlmCardDescription>{{ _t()['description'] }}</p>
<div hlmCardAction>
<button hlmBtn variant="link">{{ _t()['signUp'] }}</button>
</div>
</hlm-card-header>
<div hlmCardContent>
<form id="login-form-rtl">
<div class="flex flex-col gap-6">
<div class="grid gap-2">
<label hlmLabel for="email">{{ _t()['email'] }}</label>
<input type="email" id="email" placeholder="{{ _t()['emailPlaceholder'] }}" required hlmInput />
</div>
<div class="grid gap-2">
<div class="flex items-center">
<label hlmLabel for="password">{{ _t()['password'] }}</label>
<a href="#" class="ms-auto inline-block text-sm underline-offset-4 hover:underline">
{{ _t()['forgotPassword'] }}
</a>
</div>
<input type="password" id="password" hlmInput />
</div>
</div>
</form>
</div>
<hlm-card-footer class="flex-col gap-2">
<button hlmBtn type="submit" class="w-full" form="login-form-rtl">{{ _t()['login'] }}</button>
<button hlmBtn variant="outline" class="w-full">{{ _t()['loginWithGoogle'] }}</button>
</hlm-card-footer>
</hlm-card>
`,
})
export class CardRtl {
private readonly _language = inject(TranslateService).language;
private readonly _translations: Translations = {
en: {
dir: 'ltr',
values: {
title: 'Login to your account',
description: 'Enter your email below to login to your account',
signUp: 'Sign Up',
email: 'Email',
emailPlaceholder: 'm@example.com',
password: 'Password',
forgotPassword: 'Forgot your password?',
login: 'Login',
loginWithGoogle: 'Login with Google',
},
},
ar: {
dir: 'rtl',
values: {
title: 'تسجيل الدخول إلى حسابك',
description: 'أدخل بريدك الإلكتروني أدناه لتسجيل الدخول إلى حسابك',
signUp: 'إنشاء حساب',
email: 'البريد الإلكتروني',
emailPlaceholder: 'm@example.com',
password: 'كلمة المرور',
forgotPassword: 'نسيت كلمة المرور؟',
login: 'تسجيل الدخول',
loginWithGoogle: 'تسجيل الدخول باستخدام Google',
},
},
he: {
dir: 'rtl',
values: {
title: 'התחבר לחשבון שלך',
description: 'הזן את האימייל שלך למטה כדי להתחבר לחשבון שלך',
signUp: 'הירשם',
email: 'אימייל',
emailPlaceholder: 'm@example.com',
password: 'סיסמה',
forgotPassword: 'שכחת את הסיסמה?',
login: 'התחבר',
loginWithGoogle: 'התחבר עם Google',
},
},
};
private readonly _translation = computed(() => this._translations[this._language()]);
protected readonly _t = computed(() => this._translation().values);
protected readonly _dir = computed(() => this._translation().dir);
}Helm API
HlmCardAction
Selector: [hlmCardAction]
HlmCardContent
Selector: [hlmCardContent]
HlmCardDescription
Selector: [hlmCardDescription]
HlmCardFooter
Selector: [hlmCardFooter],hlm-card-footer
HlmCardHeader
Selector: [hlmCardHeader],hlm-card-header
HlmCardTitle
Selector: [hlmCardTitle]
HlmCard
Selector: [hlmCard],hlm-card
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| size | HlmCardConfig['size'] | this._defaultConfig.size | - |
On This Page