The UI is built by composing components. Pages do not have their own stylesheets — all styling lives in the components they compose. In a React app a page component owns both data fetching and UI composition, so the two responsibilities live together in one file. In a Rails full-stack app the split is clearer: data handling belongs in the controller and the view component is purely about composing presentational components.
Working on components means close collaboration with designers and Figma. Before implementing anything, the first place to check is the shaping section of the GitHub issue — it will tell you what components are expected and whether they are new or already exist. Components are named in code the same way they are named in Figma, so the design file is a reliable indicator of whether a component has already been implemented. If the project has Storybook set up, that is another quick way to browse what is already available before writing anything new.
Once a feature is implemented, test it across different browsers and operating systems before marking it done. Visual bugs that only appear on specific platforms are common and easy to catch early with a quick cross-browser check.
Presentational components own their markup and styling. They receive all their data through props, make no API calls, and hold no application state — they are only responsible for how things look.
When building a component or page, reach for these primitives first and compose them together. If a layout or styling requirement cannot be met with the available primitives, check whether a new primitive is warranted before adding ad hoc markup. The rule of three applies: only extract a new primitive once the same pattern has appeared independently in at least three places. A one-off layout belongs in the component that needs it, not in shimmer-components.
Each component lives in its own folder under src/components, containing exactly two files: the component itself and its stylesheet.
src/components/document_card/document_card.tsx
src/components/document_card/document_card.scss
The folder name, file names, and BEM block in the stylesheet must all match. The component identifier in code uses PascalCase (DocumentCard) while the file and folder use snake_case.
When a component needs to be targeted by JavaScript (e.g. for event delegation or test selectors), use data- attributes rather than IDs or class names. Name them after the role, not the implementation: data-action="submit" rather than data-button="true". Never use BEM class names as JS hooks — classes are for styling only.
Every project ships the same set of low-level layout and typography components. These primitives are provided by the shimmer-components library and should be the first thing you reach for when composing any component or page. Do not re-implement layout patterns from scratch — use these instead.
If a primitive you need does not exist in shimmer-components, implement it according to the correct Figma design in the project before using it. Do not add ad hoc markup as a substitute for a missing primitive.
Note:
GridandIconare not yet part of shimmer-components. Copy the implementation from an existing project — ask a senior developer for the recommended source.
Use Grid whenever you need to arrange content in a two-dimensional layout. Define column, row, and gap settings per breakpoint using the mobile, tablet, desktop, and desktopLg props.
<Grid
mobile={{ gap: 8 }}
desktop={{ columns: 'repeat(2, 1fr)' }}
desktopLg={{ gap: 16 }}
>
<Card />
<Card />
</Grid>
# Phlex — grid applies layout via CSS custom properties
grid(
mobile: { gap: 8 },
desktop: { columns: "repeat(2, 1fr)" },
desktop_lg: { gap: 16 }
) do
# child components
end
Prefer Grid over recreating grid layout with custom CSS in a component's stylesheet. Custom grid layouts are sometimes necessary, but reach for Grid first.
Use Icon to render an SVG icon by name. Always pass a size prop in pixels. Icon is purely presentational — it is never interactive on its own. Buttons that display an icon use a Button or IconButton component that wraps Icon.
<Icon name="arrow-new-tab" size={16} />
# Phlex
icon(name: "arrow-new-tab", size: 16)
Use Stack whenever you need to lay out a series of elements in a single direction. The default direction is vertical. Add the line prop for a horizontal row. Pass gap, align, and justify to control spacing and alignment.
{
/* Horizontal row with space between and centered alignment */
}
<Stack gap={0} line justify="space-between" align="center">
<Text type="caption-bold">{i18n.t('document.title')}</Text>
<Icon name="arrow-new-tab" size={16} />
</Stack>;
{
/* Vertical stack */
}
<Stack gap={16}>
<Text type="h3">{i18n.t('section.heading')}</Text>
<Text type="body">{i18n.t('section.description')}</Text>
</Stack>;
# Phlex — horizontal row
stack(line: true, justify: "space-between", align: "center") do
text(type: "caption-bold") { t("document.title") }
icon(name: "arrow-new-tab", size: 16)
end
# Phlex — vertical stack
stack(gap: 16) do
text(type: "h3") { t("section.heading") }
text(type: "body") { t("section.description") }
end
Stack is best suited for page-level layout and smart components where you want to control spacing without adding a new stylesheet. For complex dumb components, defining flex layout directly in the component's SCSS is often the better choice — it avoids an extra wrapper element, keeps the markup flatter, and makes viewport-specific adjustments easier to reason about in one place.
Use Box when you need to apply padding around content or constrain the width of a section. Pass a padding object with vertical, horizontal, top, bottom, left, or right values in pixels.
<Box padding={{ vertical: 16, horizontal: 24 }}>
<Stack gap={8} line align="center">
<Icon name="document" size={24} />
<Text type="caption-bold">{i18n.t('document.name')}</Text>
</Stack>
</Box>
# Phlex
box(padding: { vertical: 16, horizontal: 24 }) do
stack(gap: 8, line: true, align: "center") do
icon(name: "document", size: 24)
text(type: "caption-bold") { t("document.name") }
end
end
Avoid using Box solely for visual decoration. If you need a background color or border, that belongs in the component's own BEM styles, not in Box props.
Use Collapse to show or hide content in response to user interaction. Use it for accordions, expandable sections, and any other toggle-reveal pattern.
const [open, setOpen] = useState(false);
<Collapse open={open}>
<Button onClick={() => setOpen(!open)} />
<Text type="body">{i18n.t('section.details')}</Text>
</Collapse>;
# Phlex
collapse(open: false) do
text(type: "body") { t("section.details") }
end
Use Text for all rendered copy that requires a specific typographic style. Pass the desired variant via the type prop — the available types are generated directly from the Figma design variables (see the Figma section), so they always match what is in the design file. Never use bare HTML heading or paragraph tags when a specific style is intended.
The type and color values are defined in the project's types.ts, which is generated from Figma variables. Always use these TypeScript types rather than raw strings to keep values in sync with the design system.
Text accepts an element prop to control which HTML node is rendered, keeping semantic markup correct independently of the visual style. When no element is given, Text renders as a div — it never infers the element from the type, so visual style and semantic element are always chosen explicitly. The type already handles responsive sizing across all viewports. Additional helper props like uppercase, inline, and noWrap cover common typographic adjustments, and text color is also managed through Text rather than custom CSS.
{/* Correct */}
<Text type="h2">{i18n.t('page.title')}</Text>
<Text type="caption-bold">{i18n.t('document.label')}</Text>
{/* Wrong — do not use bare HTML tags for styled text */}
<h2>Page title</h2>
<strong>Document label</strong>
{/* Control semantic element independently of visual style */}
<Text type="h2" element="h3">{i18n.t('section.heading')}</Text>
# Phlex — correct
text(type: "h2") { t("page.title") }
text(type: "caption-bold") { t("document.label") }
# Phlex — wrong: do not use bare HTML tags for styled text
h2 { "Page title" }
strong { "Document label" }
# Control semantic element independently of visual style
text(type: "h2", element: :h3) { t("section.heading") }
Every component that can wait, fail, or return nothing must account for all three conditions explicitly. Do not leave loading, error, or empty states as afterthoughts — design and implement them alongside the happy path.
Always follow the Design team's specifications for these states and discuss implementation details with them before building. Loading skeletons, error messages, and empty states all have visual implications — do not implement them unilaterally.
If the component receives data through props, the parent is responsible for passing the correct state down. The component itself should not decide how to fetch or retry — it should only render what it receives.
All images must be in webp or svg format. Do not use png or jpeg. Use svg for icons and illustrations that need to scale without quality loss. Use webp for photographs and raster assets. Always provide meaningful alt text for accessibility.
Stylesheets are co-located with the component they style. Each component owns exactly one stylesheet file, which defines exactly one BEM block. Styles are written mobile-first: base rules target small screens and larger-screen adjustments are layered with media queries at the standard breakpoints — 768px for tablet and 1280px for desktop.
Always use CSS custom properties for colors. Color values are defined as design tokens in Figma and exported to generated-variables.scss (see the Figma section). This keeps color themes centralized and supports future additions like dark mode.
Never use hex color values in stylesheets. This includes bare hex values and hex fallbacks inside var() calls. If a variable is missing, the broken color should be visible so it gets fixed — not silently hidden by a fallback. If a variable does not exist in generated-variables.scss, ask the designer to add it in Figma and re-export.
// Wrong — bare hex value
background: #fff;
// Wrong — hex fallback inside var(); broken colors must be visible, not hidden
background: var(--neutral-surface-default, #fff);
// Correct — variable only
background: var(--neutral-surface-default);
Do not use inline styles in markup — all styling must go through the component's stylesheet.
The only accepted exception is declaring CSS custom properties inside the base layout primitives (Stack, Box, Grid). This exception does not extend to page or feature components.
{
/* Wrong — never use inline styles */
}
<div style={{ color: 'red', marginTop: 16 }}>...</div>;
{
/* Correct — express all styling through BEM classes and the stylesheet */
}
<div className="document-card__title document-card__title--error">...</div>;
# Phlex — wrong: never use inline styles
div(style: { color: "red", margin_top: 16 }) { "..." }
# Phlex — correct: express all styling through BEM classes and the stylesheet
div(class: "document-card__title document-card__title--error") { "..." }
Do not use !important. The only accepted exception is overriding styles from a closed third-party library where no selector-specificity solution is possible.
Never target another component's classes from inside a different component's stylesheet. Rules like .stack .text {} are forbidden — each component is responsible for its own styles only and must never reach into or depend on another component's context.
Avoid overly complex selectors. In particular, avoid :has() selectors with deep or broad matches — they are evaluated continuously by the browser and can cause severe layout performance issues in Safari. If you need :has(), keep it shallow and scoped tightly to the component root.
Every styled element must carry a BEM class (naming convention reference). Do not target bare HTML tags like p, li, or h3 in stylesheets.
BEM classes follow the pattern block__element--modifier:
.card__, e.g. .card__title--, e.g. .card__title--featured// Correct
.card { ... }
.card__title { ... }
.card__title--featured { ... }
// Wrong — deep nesting
.card__body__title { ... }
// Wrong — bare HTML element selector
.card p { ... }
A few rules to keep in mind:
.card__title is correct even if the title sits inside .card__body. Never write .card__body__title.class="card__title card__title--featured". Never use a modifier on its own.Mobile styles follow from the mobile-first base rules, but a few patterns apply specifically to small-screen layouts:
env(safe-area-inset-*). This requires viewport-fit=cover in the viewport meta tag.Accessibility is a first-class requirement, not a post-launch checklist item. Build it in from the start.
Semantic HTML — use the correct element for the job. Buttons trigger actions; links navigate. Do not use div or span as interactive elements without the appropriate ARIA role. The Text component accepts an element prop so visual style and semantic element can be chosen independently.
ARIA labels — icon-only buttons must have a label that describes the action. Decorative images and icons should use aria-hidden="true" so screen readers skip them.
Since IconButton never has visible text children, label is always a required prop. Use label as the prop name and map it to aria-label internally:
// IconButton always requires a label — it has no visible text children
interface IconButtonProps {
icon: string;
label: string;
}
# Phlex — props declared with the literal gem
prop :icon, String
prop :label, String
Focus management — use :focus-visible rather than :focus to show focus rings only for keyboard users. Never suppress the focus outline entirely. When a modal or dialog opens, move focus into it; when it closes, return focus to the trigger.
Hover states — wrap hover styles in @media (hover: hover) so they only apply on devices that support pointer hover. Touch-only devices should never receive hover styles.
Keyboard navigation — all interactive elements must be reachable and operable with a keyboard. Verify tab order is logical. Custom interactive components (dropdowns, dialogs) must handle Escape to close and arrow keys where appropriate.
Visually hidden text — use a .visually-hidden CSS utility class (not display: none or visibility: hidden) to provide context for screen readers without affecting the visual layout.
Color contrast — text and meaningful UI elements must meet WCAG AA contrast ratios. Do not communicate state through color alone.
Figma is the source of truth for all visual design. Text sizes and colors are defined there as variables. To generate the corresponding CSS custom property rules, use the figma-export-variables-plugin:
manifest.json file.Components in Figma are designed at three breakpoints: mobile, tablet, and desktop. Always implement from the smallest breakpoint upward, adding adjustments for larger screens as you go. This keeps styles mobile-first by default and avoids having to override rules downward.
All user-facing text must come from locale files — never hardcode strings directly into markup. This applies to labels, button text, headings, error messages, and any other copy visible to the user.
The Figma file for the screen is the source of truth for all copy. Always take text content from the Figma design for that screen, not from assumptions or placeholder text.
{
/* Correct */
}
<Text type="body">{i18n.t('dashboard.welcome_message')}</Text>;
{
/* Wrong — hardcoded string */
}
<Text type="body">Welcome back!</Text>;
# Phlex — correct
text(type: "body") { t("dashboard.welcome_message") }
# Phlex — wrong: hardcoded string
text(type: "body") { "Welcome back!" }
Locale keys are organized by feature using namespaces, for example navigation.dashboard or dashboard.current_month_overview. Leaf keys use lower_snake_case and should be named after the meaning of the text, not its literal content.
Each page must implement its own copy — do not use generic or shared translations. Sharing a key across multiple screens makes it impossible to update copy for one page without affecting others.
Keys must be derived from the copy they represent, not numbered sequentially:
# Wrong
paragraph_1: 'Are you sure you want to delete this item?'
paragraph_2: 'This action cannot be undone.'
paragraph_3: 'Confirm deletion'
# Correct
are_you_sure_you_want_to_delete_this_item: 'Are you sure you want to delete this item?'
this_action_cannot_be_undone: 'This action cannot be undone.'
confirm_deletion: 'Confirm deletion'
When adding a new piece of UI text, add the key to the locale source file before using it in the component. Consult the project's own documentation for the canonical location of that file.
Use a Formatter class for currency, percentages, dates, and datetimes. Never format these values inline or with hand-rolled string manipulation. The class is carried over from project to project — check an existing project for the canonical source before implementing from scratch.
In React, instantiate it with the active locale (typed to the project's Locale type from i18n.ts) so all output stays consistent with the user's language settings. The class wraps the Intl APIs and handles null/invalid inputs gracefully.
export class Formatter {
currency(value: number | string): string {}
percentage(value: number): string {}
date(value: Date | string): string | null {}
datetime(value: Date | string): string | null {}
}
In Rails, use the built-in i18n helpers:
number_to_currency(invoice.total, unit: '€')
number_to_percentage(value, precision: 1)
l(invoice.due_date, format: :long)
l(invoice.created_at, format: :long)
Each component in React is a single exported function declared with function, not an arrow function. The file exports exactly one component, named identically to the file. The return type must be declared explicitly as ReactElement. Do not use React.FC.
Phlex follows the same one-component-per-file rule. Props are declared with the literal gem rather than a TypeScript interface.
interface Props {
title: string;
url: string;
}
export function DocumentCard({ title, url }: Props): ReactElement {
return (
<Box padding={{ vertical: 16, horizontal: 24 }}>
<Text type="caption-bold">{title}</Text>
</Box>
);
}
Props must always be declared with an explicit interface. Declare only the specific fields the component needs — prefer Pick over accepting a full model type.
// Wrong — accepts the entire User object when only two fields are needed
interface Props {
user: User;
}
// Correct — declare only what is actually used
interface Props {
user: Pick<User, 'name' | 'email'>;
}
In Phlex, use prop :field, Type from the literal gem and declare only the fields the component needs — the same principle applies.
prop :name, String
prop :email, String
Avoid useEffect unless there is no other option. Common component-level misuses to avoid:
useMemoBefore reaching for useEffect, ask whether the same outcome can be achieved without it.