
Phoenix Frontend
- 26 installs
- 10.9k repo stars
- Updated August 4, 2026
- arize-ai/phoenix
phoenix-frontend is a Claude Code skill of frontend development guidelines for building React and TypeScript UI in the Phoenix observability platform's app/ directory.
About
phoenix-frontend provides frontend development guidelines for the Phoenix AI observability platform. Developers use it when writing, reviewing, or modifying React components, TypeScript, styles, or UI features in the app/ directory. It routes to rule files on components, Relay, accessibility, and logo assets, and requires that significant view state be recreatable from the URL and that visual changes be verified with agent-browser.
- Frontend development guidelines for the Phoenix app/ directory
- Rule files for components, Relay, accessibility, and SVG logo assets
- Requires encoding significant view state in the URL and verifying UI with agent-browser
Phoenix Frontend by the numbers
- 26 all-time installs (skills.sh)
- Ranked #1,495 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
phoenix-frontend capabilities & compatibility
- Capabilities
- react component conventions · relay usage · accessibility rules · url state management
- Use cases
- frontend · ui design
- Runs
- Runs locally
- Pricing
- Free
What phoenix-frontend says it does
Frontend development guidelines for the Phoenix AI observability platform.
After visual changes, use `agent-browser` to verify the UI looks correct.
Significant view state must be recreatable from the URL.
npx skills add https://github.com/arize-ai/phoenix --skill phoenix-frontendAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| repo stars | ★ 10.9k |
| Last updated | August 4, 2026 |
| Repository | arize-ai/phoenix ↗ |
What it does
Follow Phoenix frontend conventions when building or refactoring React/TypeScript components, styles, and UI features in the app/ directory.
Who is it for?
Developers writing or refactoring React/TypeScript components in the Phoenix app
Skip if: Design-system rules like tokens, dialogs, and error display, which live in the phoenix-design skill
When should I use this skill?
You are writing, reviewing, or modifying React components, TypeScript, styles, or UI features in app/
What you get
UI code that follows Phoenix component, Relay, and accessibility rules with view state encoded in the URL
- React/TypeScript components following Phoenix conventions
- URL-encoded view state
- accessible UI
By the numbers
- 4 rule files: components, relay, accessibility, resize-svg-logo-assets
Files
Phoenix Frontend Development
Composable rules for building UI in the Phoenix app. Before starting work, explore app/src/components/ and app/package.json to understand existing patterns, packages, and conventions — then follow these rules.
Rule Files
Read the relevant file(s) based on the task:
| Rule file | When to read |
|---|---|
rules/components.md | Creating, composing, or refactoring components |
rules/relay.md | Using Relay |
rules/accessibility.md | Any interactive element, form, overlay, or semantic markup |
rules/resize-svg-logo-assets.md | Adding or updating provider/integration logo icons |
Verification
After visual changes, use agent-browser to verify the UI looks correct. When modifying a shared component, check its usages across the app.
URL State
Significant view state must be recreatable from the URL. If a user can select a tab, sub-view, or detail state that should survive reloads, sharing, or adjacent-record pagination, encode it in route params or search params and preserve the relevant URL state during navigation.
Accessibility
Semantic elements
- Interactive actions MUST use buttons, not clickable divs
- Lists MUST use
<ul>/<ol>
WCAG 2.1 AA baseline
WCAG 2.1 AA MUST be followed: 4.5:1 contrast for text, keyboard operability, visible focus indicators, labeled form inputs.
Component Patterns
Core vs. domain split
Explore app/src/components/ to understand the two layers:
- Core (
components/core/) — presentational primitives. MUST NOT contain data fetching or business logic. - Domain (everything else) — data-rich, composed from core primitives.
New features MUST compose from existing core primitives before creating new low-level UI.
Layout
Existing flex and view layout primitives SHOULD be used for consistent spacing and alignment. Explore components/core/ for what's available before writing ad-hoc layout wrappers.
Storybook
New core components MUST include minimal Storybook stories showing primary variants and states. Explore app/stories/ for the existing convention.
File convention
Explore a few existing components to match the established file structure (component, styles, types, barrel export).
Memoization
React Compiler is enabled — do NOT use useMemo, useCallback, or React.memo. The compiler handles memoization automatically. This includes callback props passed to children; define them inline without wrapping in useCallback.
Refs
React 19 treats ref as a regular prop — do NOT use forwardRef. Instead, accept ref directly in the props type and destructure it like any other prop. Add ref?: Ref<ElementType> to the props interface.
Callback props
Name callback props after the event (onProjectCreated, onDismiss), not the parent's implementation (refetchProjects). The callsite should read as plain English.
// Good
<NewProjectButton onProjectCreated={() => refetchProjects()} />
// Avoid
<NewProjectButton refetchProjects={() => refetchProjects()} />Avoid useEffect
Prefer declarative React and React Aria patterns over imperative useEffect. Reach for useEffect only when there is no declarative alternative.
Overlays: Drawer vs. Modal
Phoenix has two overlay components with different purposes. Choosing the wrong one breaks the interaction model.
Drawer (components/core/overlay/Drawer.tsx)
A non-modal right-side panel. Use for list-detail flows where the user selects a row and inspects its details while keeping the list visible and interactive behind it.
- Does NOT render a backdrop — clicks pass through to the page behind it
- Resizable via drag handle; width persists to
localStorageas a viewport percentage - Dismissed with Escape or the collapse arrow button
- Route-driven: opening a drawer means navigating to a nested route (e.g.,
/sessions/:sessionId) - The corresponding table row MUST be highlighted with
data-selectedso the user always knows which item they are viewing
Use Drawer when: the user needs to glance at details and return to the list without losing context (traces, spans, sessions).
Modal (components/core/overlay/Modal.tsx)
A modal overlay that demands focus. Comes in two variants:
variant="default"— centered dialog with backdrop. Use for confirmations, forms, and focused workflows that require the user's full attention.variant="slideover"— full-height right-side panel with backdrop. Use when the content needs more space than a centered dialog but still requires modal focus (e.g., a complex creation form that shouldn't compete with the page behind it).
Both variants block interaction with the page behind them via ModalOverlay.
Decision guide
| Signal | Use |
|---|---|
| User is browsing a list and inspecting items | Drawer |
| User must complete an action before continuing | Modal (default) |
| Modal content needs full-height panel layout | Modal (slideover) |
UX: list-detail pattern
Tables that open a detail view on row click MUST follow the layered list-detail pattern:
1. Row click navigates to a nested route (e.g., /traces/:traceId). The detail view renders via <Outlet /> alongside the table. 2. Selected row is highlighted by setting data-selected={isSelected} on the <tr>, where isSelected compares row.original.id against the URL param (via useParams). The existing selectableTableCSS styles tr[data-selected="true"] automatically. 3. Drawer opens with the detail content. The table remains visible and scrollable behind it. 4. Closing the drawer navigates back to the parent route, clearing the selection.
This pattern keeps the user oriented — they always see which row they selected and can click a different row to switch without closing first.
Shared constants
Shared literals (validation regexes, fixed string sets, etc.) belong in app/src/constants/ as focused modules re-exported from index.ts. Import via @phoenix/constants. Do not export them from component or form files.
Data Fetching
Relay store and cache retention
Declarative Relay hooks (usePreloadedQuery, useLazyLoadQuery) retain their query and fetched data in the Relay store cache for as long as the component using the hook is mounted. This makes them safe for hydrating data that is rendered on a page.
fetchQuery does not have this retention guarantee. Data fetched via fetchQuery can be evicted from the Relay store after enough subsequent requests (e.g., requests triggered by pagination). This means using fetchQuery to hydrate data that is rendered on a page is dangerous — the data may silently disappear from the store while the component is still mounted.
Query refs returned by loadQuery are retained until they are disposed. Use useQueryLoader when the component owns loading. When a route loader or other external owner returns a loadQuery ref directly to a component, that component must dispose the ref when it stops owning it.
Owned preloaded queries
Phoenix provides app/src/hooks/useOwnedPreloadedQuery.ts for the common route-loader pattern:
- Loader uses
loadQuery(...)and returns a query ref. - Component reads the ref with
useOwnedPreloadedQuery(...). - The hook hands the ref to
useQueryLoader, so Relay disposes it on replacement or unmount.
Use this hook only when the current component owns the lifecycle of an externally created query ref, such as useLoaderData() returning a loadQuery result.
Do not use this hook when:
- the query ref is already managed by
useQueryLoader - the ref is shared and another component owns disposal
- the ref is passed through context or props for multiple readers without clear single-owner semantics
Rules
- Prefer declarative hooks. Use hooks such as
usePreloadedQueryoruseLazyLoadQueryto fetch data that will be rendered on a page. - Avoid `fetchQuery` for page-rendered data. Do not use
fetchQueryto hydrate data that a mounted component depends on for rendering. - Limited safe uses of `fetchQuery`.
fetchQueryis acceptable when the result is consumed immediately and not held in the store for rendering, such as fetching data for a redirect or a one-shot action. - Prefer `useQueryLoader` for component-owned query refs. It handles retaining and disposal for refs the component loads itself.
- Use `useOwnedPreloadedQuery` for loader-owned query refs. If a route loader returns a
loadQueryref that this component owns directly, read it withuseOwnedPreloadedQueryinstead ofusePreloadedQuery. - Treat disposal as an ownership decision. Only the owner should dispose a query ref. Disposing a shared ref too early can make still-mounted readers hit missing-data or GC-related crashes later.
Resize SVG Logo Assets
Add provider or integration logo SVGs to the Phoenix frontend. Accepts raw SVGs, deterministically rescales them, converts to JSX, and inserts into the appropriate TSX file.
Targets
| Target | Canvas | File | Component style |
|---|---|---|---|
provider | 24x24 | app/src/components/generative/GenerativeProviderIcon.tsx | Private const, accepts { height } prop |
integration | 32x32 | app/src/components/project/IntegrationIcons.tsx | Named export, no props, fixed 32x32 |
If the user does not specify a target, ask before proceeding.
Workflow
Step 1 — Collect SVGs
Accept input as:
- A single SVG file path
- A list of SVG file paths
- A directory containing
.svgfiles - Raw SVG markup pasted in the conversation (write to a temp file first)
Step 2 — Resize with the deterministic script
Use the helper script at .agents/skills/phoenix-frontend/scripts/scale-svg.py.
Single file:
uvx --with svgpathtools python .agents/skills/phoenix-frontend/scripts/scale-svg.py <target_size> <input.svg> <output.svg>Batch (directory):
uvx --with svgpathtools python .agents/skills/phoenix-frontend/scripts/scale-svg.py --batch <target_size> <input_dir> <output_dir>Target sizes:
provider→24integration→32
Step 3 — Convert SVG to JSX
After resizing, read each output SVG and convert to a JSX component. Apply these attribute transformations:
| SVG attribute | JSX attribute |
|---|---|
class | className |
clip-path | clipPath |
clip-rule | clipRule |
fill-rule | fillRule |
fill-opacity | fillOpacity |
stop-color | stopColor |
stop-opacity | stopOpacity |
stroke-width | strokeWidth |
stroke-linecap | strokeLinecap |
stroke-linejoin | strokeLinejoin |
stroke-dasharray | strokeDasharray |
stroke-dashoffset | strokeDashoffset |
stroke-opacity | strokeOpacity |
xmlns:xlink | (remove) |
Also:
- Remove the
xmlnsattribute from the root<svg>element (React adds it automatically). - Self-close elements with no children (e.g.,
<path ... />). - Keep the
viewBoxattribute as-is (it is already camelCase).
Step 4 — Apply color rules
- If the logo is exclusively one dark color (black, near-black, dark gray), replace the fill with
currentColorso it follows Phoenix theme in light/dark mode. - If the logo has explicit brand colors (multicolor), preserve them.
Step 5 — Insert into TSX file
Before adding to the file, confirm that the logos do not conflict with any existing entries. If there are similar logos already in place, list them and ask for confirmation before overwriting.
Provider icons (GenerativeProviderIcon.tsx)
1. Add the SVG as a private component following this pattern:
const NewProviderSVG = ({ height }: { height: number }) => (
<svg
viewBox="0 0 24 24"
width={height}
height={height}
xmlns="http://www.w3.org/2000/svg"
>
{/* scaled paths here */}
</svg>
);2. Add an entry to the PROVIDER_ICONS record:
NEW_PROVIDER: NewProviderSVG,Note: The key must match a value in the ModelProvider type. If the provider doesn't exist in the type yet, flag this to the user — the type may need updating upstream.
Integration icons (IntegrationIcons.tsx)
Add as a named export following this pattern:
export const NewIntegrationSVG = () => (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* scaled paths here */}
</svg>
);Step 6 — Derive component name from filename
When the SVG filename follows the icon=Name.svg convention, derive the component name as NameSVG. For example:
icon=LlamaIndex.svg→LlamaIndexSVGicon=Cerebras.svg→CerebrasSVGicon=LiveKit Agents.svg→LiveKitAgentsSVG
Strip the icon= prefix, remove spaces, and append SVG.
Step 7 — Verify
- Read the modified TSX file to confirm the new component compiles with
surrounding code.
- If the user provides a list of expected icon names, confirm each was added.
Workflow efficiency
- Work file-by-file, not in bulk. Scale an SVG, read the output, edit the
TSX — then move to the next. Do not dump all SVG contents into context or agent prompts as a batch.
- Don't marshal file contents through conversation. The scaled files are on
disk — read them directly when needed instead of buffering them into a message.
- Replace whole components when updating. If an existing SVG component needs
replacing, delete the old one entirely and write the new one fresh. Don't try to patch parts of it.
Safety
- Never hand-edit SVG path data. All coordinate changes must go through the
scale script.
- Never guess or approximate path geometry.
- If the scale script fails on a particular SVG, report the error to the user
rather than attempting a manual fix.
- Do not modify existing icons in the file unless explicitly asked.
#!/usr/bin/env python3
"""Scale SVG files deterministically by rewriting all path coordinates
and positional attributes to native values at the target size.
Usage:
uvx --with svgpathtools python scale-svg.py <target_size> <input> <output>
uvx --with svgpathtools python scale-svg.py --batch <target_size> <input_dir> <output_dir>
Produces output equivalent to resizing in Figma — coordinates are
rewritten mathematically, not wrapped in a transform group.
"""
import argparse
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from svgpathtools import ( # type: ignore[import-not-found]
Arc,
CubicBezier,
Line,
QuadraticBezier,
parse_path,
)
# Attributes holding coordinate/length values that need scaling
SCALE_X = {"x", "x1", "x2", "cx", "width", "rx", "dx"}
SCALE_Y = {"y", "y1", "y2", "cy", "height", "ry", "dy"}
SCALE_UNIFORM = {"r", "stroke-width"}
SKIP_TAGS = {"style", "script"}
def _fmt(v: float) -> str:
"""Format a number: max 4 decimal places, strip trailing zeros."""
return f"{v:.4f}".rstrip("0").rstrip(".")
def scale_path_d(d: str, sx: float, sy: float) -> str:
"""Parse an SVG path d-attribute and scale all coordinates.
Handles multiple subpaths (disconnected M commands) by detecting
when a segment's start doesn't match the previous segment's end.
"""
path = parse_path(d)
parts: list[str] = []
if not path:
return d
prev_end = None
for seg in path:
# Emit a new M command when the subpath is discontinuous
# (start of path, or a new moveto)
if prev_end is None or abs(seg.start - prev_end) > 1e-6:
parts.append(f"M{_fmt(seg.start.real * sx)},{_fmt(seg.start.imag * sy)}")
if isinstance(seg, Line):
parts.append(f"L{_fmt(seg.end.real * sx)},{_fmt(seg.end.imag * sy)}")
elif isinstance(seg, CubicBezier):
parts.append(
f"C{_fmt(seg.control1.real * sx)},{_fmt(seg.control1.imag * sy)} "
f"{_fmt(seg.control2.real * sx)},{_fmt(seg.control2.imag * sy)} "
f"{_fmt(seg.end.real * sx)},{_fmt(seg.end.imag * sy)}"
)
elif isinstance(seg, QuadraticBezier):
parts.append(
f"Q{_fmt(seg.control.real * sx)},{_fmt(seg.control.imag * sy)} "
f"{_fmt(seg.end.real * sx)},{_fmt(seg.end.imag * sy)}"
)
elif isinstance(seg, Arc):
parts.append(
f"A{_fmt(seg.radius.real * sx)},{_fmt(seg.radius.imag * sy)} "
f"{seg.rotation} "
f"{int(seg.large_arc)},{int(seg.sweep)} "
f"{_fmt(seg.end.real * sx)},{_fmt(seg.end.imag * sy)}"
)
prev_end = seg.end
return " ".join(parts)
def scale_element(el: ET.Element, sx: float, sy: float) -> None:
"""Recursively scale all coordinate attributes and path data."""
tag = el.tag.split("}")[-1] if "}" in el.tag else el.tag
if tag in SKIP_TAGS:
return
# Scale path d attribute
if "d" in el.attrib:
el.attrib["d"] = scale_path_d(el.attrib["d"], sx, sy)
# Scale positional/size attributes
for attr in list(el.attrib.keys()):
plain = attr.split("}")[-1] if "}" in attr else attr
val = el.attrib[attr]
try:
num = float(val)
except ValueError:
continue
if plain in SCALE_X:
el.attrib[attr] = _fmt(num * sx)
elif plain in SCALE_Y:
el.attrib[attr] = _fmt(num * sy)
elif plain in SCALE_UNIFORM:
el.attrib[attr] = _fmt(num * (sx + sy) / 2)
# Scale points attribute (polyline, polygon)
if "points" in el.attrib:
pairs = el.attrib["points"].strip().split()
scaled: list[str] = []
for pair in pairs:
coords = re.split(r"[,\s]+", pair)
if len(coords) == 2:
scaled.append(f"{_fmt(float(coords[0]) * sx)},{_fmt(float(coords[1]) * sy)}")
el.attrib["points"] = " ".join(scaled)
for child in el:
scale_element(child, sx, sy)
def scale_svg(input_path: str, target_size: int, output_path: str) -> None:
"""Scale an SVG file to target_size x target_size."""
ET.register_namespace("", "http://www.w3.org/2000/svg")
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
tree = ET.parse(input_path)
root = tree.getroot()
vb = root.attrib.get("viewBox", "")
parts = re.split(r"[\s,]+", vb)
if len(parts) != 4:
print(f"ERROR: unexpected viewBox '{vb}' in {input_path}", file=sys.stderr)
sys.exit(1)
src_w, src_h = float(parts[2]), float(parts[3])
sx = target_size / src_w
sy = target_size / src_h
print(
f" {Path(input_path).name}: {src_w}x{src_h} -> "
f"{target_size}x{target_size} (scale: {sx:.4f})"
)
root.attrib["width"] = str(target_size)
root.attrib["height"] = str(target_size)
root.attrib["viewBox"] = f"0 0 {target_size} {target_size}"
for child in root:
scale_element(child, sx, sy)
tree.write(output_path, xml_declaration=False)
def main() -> None:
parser = argparse.ArgumentParser(description="Scale SVG files deterministically")
parser.add_argument("--batch", action="store_true", help="Process a directory of SVGs")
parser.add_argument("target_size", type=int, help="Target square size (e.g. 24 or 32)")
parser.add_argument("input", help="Input SVG file or directory (with --batch)")
parser.add_argument("output", help="Output SVG file or directory (with --batch)")
args = parser.parse_args()
if args.batch:
in_dir = Path(args.input)
out_dir = Path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
svg_files = sorted(in_dir.glob("*.svg"))
if not svg_files:
print(f"No .svg files found in {in_dir}", file=sys.stderr)
sys.exit(1)
print(f"Scaling {len(svg_files)} SVGs to {args.target_size}x{args.target_size}:")
for svg in svg_files:
scale_svg(str(svg), args.target_size, str(out_dir / svg.name))
else:
scale_svg(args.input, args.target_size, args.output)
print("Done.")
if __name__ == "__main__":
main()
Related skills
FAQ
Where should view state be stored?
Significant view state must be recreatable from the URL, encoded in route params or search params.
How are visual changes verified?
Use agent-browser to verify the UI looks correct after visual changes.