Getting started
Common workarounds
What agents rebuild by hand, and the component or rule that already replaces it.
If you are about to write CSS, a setTimeout, a wrapper div, a local copy of a component or a // ca-ui-workaround, check this table first. Every row below exists on main and has an open workaround in a real app behind it.
Imports are from @codeatlasdev/ui unless the last column names a subpath.
| You are about to write | Use this instead | Where |
|---|---|---|
A hand-written <a target="_blank" rel="noopener"> |
ExternalLink: sets rel, shows the icon, adds a localized “opens in a new tab” |
ExternalLink |
A CSV or JSON download built with Blob and an anchor |
ExportMenu for a button, or toCsv, toJson and downloadFile for your own trigger |
ExportMenu |
dialog.ca-dialog--split:has(.x) { width: …; height: … }, a .ca-dialog__body { display: flex } rule and a 639px media query to go full screen |
<Dialog size="split" splitSize={{ width, height }} splitColumns="2fr 3fr" fullscreenBelow="sm"> |
Dialog |
.ca-dnd-indicator { display: none } to drop the insertion line of a board column |
sortable({ drop: 'container' }) (the column is the target, no line, to.index 0) |
Drag and drop |
confirm() or a hand-made confirm dialog |
AlertDialog: onconfirm may be async, plus destructive and pending |
AlertDialog |
| Appending streamed rows (SSE, WebSocket, a bulk job) to an array and rendering them | LiveList: batched announcements, follow-the-tail that pauses on scroll, never moves focus |
LiveList |
setTimeout(() => input.focus()) for a field in a new view |
primeKeyboard() in the tap handler, then use:autofocus on the field |
Mobile, @codeatlasdev/ui/actions |
Stack gap="4" around fields inside a form |
FormSection layout="grid", "list" or "stack" |
AppForm |
| A local brand strip on auth pages, or a local brand name beside a custom logo | AuthLayout: logoMark, logoName and mobileBrand. The brand snippet replaces the mark and the default name; logoName shows beside it only when you set it |
AuthLayout |
A position: fixed full-screen mobile view |
MobilePane |
MobilePane |
| A sticky save bar | StickyActions, or the footer snippet of AppForm |
StickyActions |
A .cover { aspect-ratio: 16 / 9; object-fit: cover } class on an image, or a padding-top hack around an iframe |
<Image ratio={16 / 9}> for a picture (fit="contain" letterboxes), <AspectRatio ratio={16 / 9}> for a video or an embed |
Image, AspectRatio |
A CSS grid with a drag handle and mousemove listeners for a resizable side panel |
ResizablePanels with ResizablePanel and ResizableHandle: keyboard resize, minSize / maxSize, persist |
ResizablePanels |
A overflow-x: auto; scroll-snap-type: x mandatory strip with hand-made arrows and dots |
Carousel and CarouselItem: slides inert off-screen, labelled “n of m”, dots, opt-in autoplay with Pause |
Carousel |
| A custom “nothing here” paragraph | EmptyState, or the empty snippet of AppList |
EmptyState |
| A hand-made loading flash or a spinner that blinks on fast loads | LoadingBoundary for a route or section, Skeleton for content shape |
LoadingBoundary |
| Your own copy-to-clipboard button | CopyButton: swaps to a check, announces the result |
CopyButton |
Adjacent Buttons with border-radius: 0 and a margin-left: -1px on the siblings |
ButtonGroup: attached buttons with a single-line seam, role="group", orientation |
ButtonGroup |
A div with a border around a bare <input>, a $ span and a button, with :focus-within styles |
TextField prefix / suffix (string for text, snippet for a button) |
TextField |
Your own toast stack or aria-live region |
toast.success() with one Toaster, or announce() for speech only |
Toast |
A .overdue pill class with color-mix, a 6px .dot span and a fixed height |
<Badge tone="danger" subtle dot size="md">Overdue</Badge> |
Badge |
@media (max-width: 767px) { .ca-btn__trailing { display: none } } to hide a Kbd hint |
<Button trailingHint> with the Kbd in trailing |
Button |
justify-content: flex-start and margin-left: auto on .ca-btn__trailing |
<Button align="between" fullWidth> |
Button |
100vh or 100dvh |
height: var(--ca-vh) |
Mobile |
cursor: pointer on a clickable div role="button", summary or label for, or cursor: not-allowed on a disabled one |
Nothing: the reset layer (@layer ca.reset) already sets pointer on button, a[href], label[for], summary and [role=button|tab|option|menuitem*], and not-allowed on :disabled and [aria-disabled=true]. An unlayered app rule such as .tile { cursor: grab } overrides it with no !important |
Theming |
line-height: 1.7 (or any unitless value) on a code font, or a highlight layer whose line height does not equal the text layer’s, so a caret and an active-line band drift apart |
line-height: var(--ca-code-line-height) (integer px) and font-family: var(--ca-font-mono) on every layer of the code surface |
CodeSnippet |
An edge peek, rail flyout or hover panel that opens while a dialog is open, or a document.querySelector('dialog[open]') check before opening it |
layers.modalOpen (reactive) or layers.blocks(trigger). Tooltip, HoverCard and NavigationMenu already stay shut under a modal |
Dialog |
A min-height: 44px touch override |
Nothing: controls grow to 44px under (pointer: coarse) |
Mobile |
padding-bottom: env(safe-area-inset-bottom) on fixed UI |
var(--ca-safe-bottom) and the other --ca-safe-* tokens |
Mobile |
| A keyboard-padding hack for a form or composer | use:keyboardAware |
Mobile, @codeatlasdev/ui/actions |
Local dark mode state, a class toggle on <html> |
ThemeToggle and the colorScheme store |
ThemeToggle, Theming |
| A wrapper that forces a dark or light island | data-theme-scope="dark" or "light" on the element |
Theming |
Inline padding, margin or gap in pixels |
Flex, Stack, Grid and Box props (gap="4"), or var(--ca-space-N) in a class |
Layout primitives |
A raw hex, rgb() or oklch() color |
A --ca-* token |
Theming |
| Table rows rebuilt as cards on a phone | SimpleTable responsive="cards"; add data-label on a td only to change its caption |
SimpleTable |
A toolbar div with flex: 1 spacers |
Flex, the toolbar snippet of AppList, or the ca-toolbar-spacer class |
AppList |
Visually hidden text with display: none or clip rules |
The ca-sr-only class |
Accessibility |
useState, useEffect, useMemo ported line by line |
$state, $derived, $effect |
Migrate from React |
html { font-size: 16px } to stop --ca-font-size-body1 rescaling every rem |
Obsolete: body text no longer follows the root rem. Override --ca-font-size-body1 only; the root rem stays 15px |
Typography |
A StatusChip, Chip or Badge moved out of PageShell actions so it does not stretch |
Keep it in actions: it keeps its natural width. Only Button goes full-width on a phone |
PageShell |
| A map or canvas mounted by hand behind the shell, or remounted on every route | AppShellLayout stage snippet; stageVisible={false} hides it on a full-page route without unmounting |
AppShell |
| Hand-rolled maplibre or mapbox plus deck.gl glue with a theme-following style | MapStage, MapControls and useMapTheme from @codeatlasdev/ui/map; PageFrame for the floating panel or bottom sheet, which reports the covered area on stageInsets |
Map, PageFrame |
| A search or ⌘K button positioned over the rail; an own “More” tab for more than 5 mobile modules; captions under rail icons | AppShellLayout: railTop snippet; bottomNavPrimary with bottomNavOverflow and moreSheet={NavMoreSheet}; railLabels |
AppShell |
| An own show/hide password button, or a “Forgot password?” link under the field | TextField type="password" revealable; the below snippet for the link |
TextField |
| An own language picker | LocaleSwitcher: options, value, onselect, compact; put it in the trailing snippet of SiteHeader |
LocaleSwitcher |
| A page wrapper that forces another preset or scheme (a light landing in a dark app) | ThemeIsland preset scheme, plus document for a full page and setRouteTheme from @codeatlasdev/ui/kit on the server |
ThemeIsland |
| A per-preset CSS override of the primary button or the input font size | colors.dark.brandFill, brandFillHover, brandContrast and sizes.inputFontSize in the theme |
Theming |
| Own chip radio buttons for a single choice | ChipGroup: bind:value, items, label, error |
ChipGroup |
| Own toast de-duplication or cap, or a second line or undo button in a toast | toast with id replaces in place; description, action: { label, onClick }. Identical messages already dedupe (count) and 3 show at most |
Toast |
An outlined or ghost Button restyled neutral with local CSS |
tone="neutral" on Button (outlined, ghost) and IconButton |
Button |
A thinner Progress by overriding the track CSS |
Progress size="sm" or "md", or --ca-progress-height for another height |
Progress |
A card-less metric built from KpiCard with the border removed |
Stat: label, value, unit, hint, trend, trendTone, size, align |
Stat |
trend dropped and the delta written in hint because down is good (churn, cost, latency) |
trendTone="positive-down" on Stat and KpiCard; "neutral" to not judge the change |
Stat, KpiCard |
| Spreadsheet-like rows, a hand-pinned first column, group rows | SimpleTable density="compact", stickyColumns, TableGroupRow, TableRow onactivate |
SimpleTable |
| An own board with drag between columns | Kanban: columns, getId, onmove, onloadmore |
Kanban |
A side detail panel built by stripping the header and padding from a Drawer |
Drawer chromeless (plus fullBleedBelow for a full-screen phone view) |
Drawer |
A Card title header that is misaligned, or an own stack inside a card |
Card title is aligned with the content; Card gap stacks the children |
Card |
| A headline font set with local CSS; hand-written highlight or strike-through CSS | Heading size="display" or the display prop (PageShell, AuthCard, SiteHeader, EmptyState), --ca-font-display; Highlight, StrikeThrough (animated) |
Heading, Highlight, StrikeThrough |
GlassCard, GlassSurface, Row, Grid cols=, gap={16} |
Card, Surface, Flex, Grid columns=, gap="4". Run bunx @codeatlasdev/ui migrate --write |
Choosing a component |
A domain.css of --app-status-*, --app-tier-* or --app-channel-* colors |
tokens: { status: { new: { light, dark } } } on the preset: --ca-token-status-new (+ -subtle, -contrast) follows the scheme and the preset |
Domain tokens |
A 1x1 <canvas> that resolves getComputedStyle(root).getPropertyValue(token) to [r, g, b, a] |
tokenRgba("status.new") or tokenRgba("--ca-brand-main") |
Domain tokens, @codeatlasdev/ui/tokens |
| A hex computed by hand to flatten a translucent text or border color onto one background | The CSS color as written: textSecondary: "oklch(1 0 0 / 0.62)" or borderSubtle: { color: "#fff", alpha: 0.1 }. The build checks text contrast after compositing |
Porting a palette by role |
A parallel --app-text-* scale or .app-label classes of font-size, line-height and letter-spacing |
The type roles: <Text role="label">, .ca-text-label, font: var(--ca-text-label); restyle them once with type in the theme |
Type roles |
font-variant-numeric: tabular-nums and a weight on every counter or clock |
<Text role="num"> or .ca-text-num |
Type roles |
Spacing units differ
Flex, Stack, Grid and Box take spacing steps as strings (gap="4"). PageShell gap is a number in px and KpiGrid gap takes a number or a string. Check the page of the component, or get_component, before you carry a value from one to another.
Still stuck
If nothing above fits, it may be a real gap in the library.
- Look the prop or component up:
get_componentfor a name,search_demosfor a task. A demo often shows the combination you need. - If it is not there, write the smallest workaround and mark it with the issue number:
// ca-ui-workaround(#<issue>): <why>. In markup use<!-- ca-ui-workaround(#<issue>): <why> -->. - Call
report_issueto draft the sanitized issue, and open it.
Details and what never goes into a report: Reporting problems.