Design Handoff and Developer Collaboration Questions
Getting a design built as intended: the specs, redlines and acceptance criteria a designer hands to engineers, and the communication that keeps the shipped build faithful to the design. Covers what a developer-facing spec must pin down, including component states, interaction and motion detail, breakpoint behavior, edge cases and error states, accessibility notes, and the design tokens an engineer will consume. Covers how the handoff is actually carried, in Figma Dev Mode, Storybook and the sprint ticket, and where those tools stop being enough. Also working with engineers before and during the build rather than only at the moment of handoff, resolving the tension when design intent meets technical reality, and design QA after the build: verifying the implementation against intent, visual regression checks in CI, and triaging drift without needlessly blocking a release. Both sides of the seam are examinable, including the engineer mapping design variants to component props and turning a spec into a typed component contract. Not design system and token architecture, versioning or governance; not building the prototype itself; not auditing an interface against accessibility standards.
Tell me about a time you worked closely with a UX/UI designer to implement a pixel-accurate component. Describe the design handoff process, how you resolved ambiguous specifications, how you ensured responsive behavior and accessibility, and what the final outcome or improvements were.
Sample Answer
Direct answer
A strong answer names a specific handoff, not a generic process: what artifact you started from (typically a Figma file with redlines and design tokens, the named values like a specific color or spacing measurement that are shared between the design file and the code so both sides refer to the same value by name instead of a raw number), the one place the spec was ambiguous, the concrete move you made to close that gap with the designer, and how you verified the result matched intent across breakpoints and for assistive technology before calling it done.
Structured elaboration
A repeatable framework for this kind of handoff:
- Start from the artifact. Note what a decent handoff normally includes (annotated states, exported assets, design tokens) and what is missing on this one.
- When something is ambiguous (no hover state defined, no rule for what happens below a certain width), the productive move is neither to guess silently nor to block on a full re-spec. Propose two concrete options with a screenshot or a quick interactive build and let the designer pick. That closes the loop in minutes instead of a full design review cycle.
- Responsive behavior: test the widths named in the spec, and also the widths in between them, since most real layout bugs live in the gaps, not at the named breakpoints.
- Accessibility: check color contrast against WCAG (Web Content Accessibility Guidelines) AA (4.5:1 for normal text), keyboard focus order, and ARIA (Accessible Rich Internet Applications) attributes for anything custom-interactive, none of which are visible in a static mockup.
- Outcome: describe what got better for the next handoff, not just that this one shipped.
Worked example
Implementing a pricing page's plan cards from a Figma handoff that included tokens and states but no interaction spec.
The badge on the "Recommended" plan and the monthly/annual toggle had no hover or focus states defined. Rather than guess, I built two quick hover treatments in code and shared them in a ten-minute sync; the designer picked one, and we captured the decision as a new shared token so the next component with a badge would not need to re-litigate it.
The card layout also had no rule for the tablet width range between the two named breakpoints. Testing there showed the badge spilling outside the card. I flagged it with a screenshot, the designer adjusted the padding, and I implemented the fix with fluid, percentage-based padding and a minimum width instead of a pixel-fixed breakpoint hack, so it would hold up at any width in that range, not just the one I happened to test.
On accessibility, the badge's light background read as too low-contrast against its text. I proposed a darker shade already in the existing palette and confirmed it comfortably cleared the WCAG AA threshold. For the toggle, I added a visually hidden live region so the plan change was announced correctly to screen readers, something a static mockup could never have shown either of us.
The result matched design intent across mobile, tablet, and desktop, passed the accessibility review with no rework, and the badge-hover token we captured mid-project got added to the shared library, so the next component that needed a badge could reference it instead of starting the conversation over.
Trade-offs and pitfalls
Guessing instead of asking is the most common failure: it ships fast but produces a design-QA bug and erodes trust for the next handoff. The opposite failure, escalating every small ambiguity to a full design review meeting, slows delivery and trains the designer to over-specify everything up front instead of trusting a quick back-and-forth. Testing only the exact breakpoint values named in the spec, rather than the ranges between them, is the most common source of "looks right in the design file, breaks in the browser." And treating accessibility as a post-launch audit item, rather than a build-time check, means fixes land after the component has already shipped and been copied into other screens.
Given the following design tokens, write a sample CSS variables file (custom properties) that maps them for use on the web. Tokens: primary-color = #0a84ff, neutral-100 = #ffffff, spacing-4 = 16, font-base = 'Inter', 16px. Provide the CSS variable names and example usage for a button.
Sample Answer
Approach
Each token becomes one CSS custom property (a variable declared with --name and read with var(--name)), scoped on :root so it's available everywhere. Two of the given tokens need a small decision the raw values don't make for you: spacing-4 = 16 has no unit, and font-base = 'Inter', 16px actually bundles two different CSS concerns, font family and font size, that CSS itself keeps as separate properties.
CSS variables file
:root {
--color-primary: #0a84ff;
--color-neutral-100: #ffffff;
/* spacing-4 given as the unitless number 16; expressed in rem (16px
assuming the default 16px root font size) rather than px, so spacing
still scales if a user or browser changes their base font size */
--spacing-4: 1rem;
/* font-base split into its two real CSS properties. The 16px size
converts to rem on exactly the same reasoning as the spacing token:
a user who raises their browser's base font size is asking for
larger text, and a hardcoded 16px here would silently ignore that. */
--font-family-base: 'Inter', sans-serif;
--font-size-base: 1rem;
}
.button {
background-color: var(--color-primary);
color: var(--color-neutral-100);
padding: var(--spacing-4);
font-family: var(--font-family-base);
font-size: var(--font-size-base);
}
Key points
--color-primaryand--color-neutral-100map directly, one token, one variable, no unit ambiguity for colors.--spacing-4converts 16 (unitless in the token) to1rem, since 16px divided by the browser default 16px root size is exactly 1rem; using rem here means spacing keeps its relative proportion if a user increases their browser's base font size for readability, which a hardcoded16pxwould not.font-baseisn't one CSS property's value,font-familyandfont-sizeare genuinely separate CSS properties, so it becomes two variables rather than one. A CSSfontshorthand property does exist and can combine both (font: 1rem 'Inter', sans-serif), but splitting them keeps each independently overridable, useful the moment a component needs the same family at a different size.- The rem conversion has to apply to
--font-size-basetoo, not just--spacing-4, and this is the detail most often gotten half-right. If spacing scaled with the user's base font size while type stayed pinned at 16px, a reader who doubled their browser's base size would get a button whose label is still 16px sitting inside twice as much padding, which is a worse result than either choice made consistently. Pick one basis and enforce it in the token file, because a component author deciding px or rem property by property is precisely how a token system drifts back into hardcoded values. sans-serifis added as a fallback after'Inter'so text still renders reasonably if the font hasn't loaded yet or fails to load.
Edge cases
- A component needing a color the token list doesn't define yet (a hover or disabled state) shouldn't invent a new hardcoded hex value inline; that's exactly the drift a token system exists to prevent, so it should be flagged back to design as a missing token rather than improvised in code.
- If the design system later needs dark mode, these same variable names get redefined inside a
[data-theme="dark"]selector rather than renamed, so components referencingvar(--color-primary)don't need to change at all when the theme changes. - Because
--spacing-4and--font-size-baseare both expressed in rem, a component that genuinely needs a value that must NOT scale with the user's font-size setting (rare, but real for something like a fixed hairline border width, or an icon that has to align to a device pixel grid) should not reuse them; that's a deliberate exception, and it should be written as its own px-valued token rather than a raw px value inlined in the component, so the exception stays visible and countable.
Engineering requests you define an API contract for a UI-driven filter that can be implemented server-side or client-side. Draft the minimal UI-focused contract fields (JSON schema-like) and explain ergonomics engineers will appreciate for both implementations.
Sample Answer
Approach
A UI-driven filter's contract needs to describe conditions ("status equals active") and how conditions combine ("this AND (that OR the other)") without assuming which side evaluates them. The design below defines a leaf FilterCondition and a recursive FilterGroup so a server can compile it into a SQL WHERE clause and a client can compile the identical JSON into an in-memory predicate function, with no field either implementation has to guess the meaning of.
Contract (JSON Schema)
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/filter-condition.json",
"title": "FilterCondition",
"type": "object",
"properties": {
"fieldId": {
"type": "string",
"description": "Stable machine identifier for the filtered field, e.g. 'created_at'. Decoupled from the UI's display label so relabeling never breaks a saved filter."
},
"operator": {
"type": "string",
"enum": ["eq", "neq", "gt", "gte", "lt", "lte", "contains", "in", "between"]
},
"valueType": {
"type": "string",
"enum": ["string", "number", "boolean", "date"],
"description": "Declares how 'value' should be parsed, so an ambiguous case like a date written as a string is unambiguous on both sides."
},
"value": {
"description": "Scalar for eq/neq/gt/gte/lt/lte/contains; a two-item array for between; an array for in."
}
},
"required": ["fieldId", "operator", "valueType", "value"],
"additionalProperties": false
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/filter-group.json",
"title": "FilterGroup",
"type": "object",
"properties": {
"combinator": { "type": "string", "enum": ["and", "or"] },
"conditions": {
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{ "$ref": "https://example.com/schemas/filter-condition.json" },
{ "$ref": "https://example.com/schemas/filter-group.json" }
]
}
}
},
"required": ["combinator", "conditions"],
"additionalProperties": false
}
A concrete instance validating against FilterGroup (checked against the schemas above with a JSON Schema validator):
{
"combinator": "and",
"conditions": [
{ "fieldId": "status", "operator": "eq", "valueType": "string", "value": "active" },
{
"combinator": "or",
"conditions": [
{ "fieldId": "created_at", "operator": "gte", "valueType": "date", "value": "2026-01-01" },
{ "fieldId": "priority", "operator": "in", "valueType": "number", "value": [1, 2] }
]
}
]
}
Ergonomics engineers will appreciate
fieldIdis a stable identifier, not the UI's display label, so relabeling a filter in the interface never breaks a filter a user already saved.operatoris a fixed, small enum rather than a free-text string, so both a SQL query builder and a JavaScript predicate function canswitchover it exhaustively and the type checker (or a database CHECK constraint) can catch a typo before it ever reaches a query.valueTyperesolves the one genuinely ambiguous case in a JSON-based contract: a date, which JSON has no native type for, always arrives as a string, and withoutvalueTypea server-side query builder and a client-side predicate function could each guess differently about whether to compare it as text or as a date.FilterGroupnests recursively (a group can contain other groups), so "A AND (B OR C)" is representable without inventing a special case, and both implementations can walk the same tree with one recursive function instead of one function per nesting depth.
Edge cases
betweenandinboth expectvalueas an array, while every other operator expects a scalar; a real implementation validates that shape per operator, not just against the schema's own genericvaluefield, since JSON Schema alone can't cheaply express "the shape of this field depends on the value of that field" in a way both sides are guaranteed to enforce identically.- An empty
conditionsarray is disallowed (minItems: 1) because an empty group has no defined meaning: does it match everything, or nothing? Forcing at least one condition avoids that ambiguity entirely rather than picking a default either implementation might get wrong. - A
fieldIdthat doesn't exist on the current dataset (e.g. a saved filter referencing a field that was later removed) is not something this contract itself can catch; that validation is a database or client-schema-lookup concern the contract deliberately leaves out, since baking dataset-specific field names into a generic filter contract would make it a different, more coupled contract than the one asked for here.
Design a machine-readable JSON schema for UI component specifications intended to be generated from design tool exports and consumed by code generators and Storybook. Define required fields (id, displayName, variants, props, tokens, breakpoints, accessibility metadata) and provide a compact example JSON for a Button component illustrating key fields.
Sample Answer
Approach
A component spec generated from a design tool export needs to be complete enough that a code generator can scaffold a real component and Storybook (a tool for building and documenting UI components in isolation) can render every variant, without a human filling in gaps by hand. The fields below cover identity, the visual surface (variants), the programmatic surface (props), the design-token dependencies, responsive behavior, and accessibility, since a spec missing any one of those forces a human back into the loop for exactly the part automation was supposed to remove.
Schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "ComponentSpec",
"type": "object",
"properties": {
"id": { "type": "string", "description": "Stable identifier, e.g. the design tool's component key. Never regenerated on rename." },
"displayName": { "type": "string", "description": "Human-readable name shown in Storybook and generated docs." },
"variants": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"description": "Named visual variants, e.g. primary/secondary/destructive."
},
"props": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "type": "string" },
"enumValues": { "type": "array", "items": { "type": "string" } },
"required": { "type": "boolean" },
"default": {}
},
"required": ["name", "type", "required"],
"additionalProperties": false
}
},
"tokens": { "type": "array", "items": { "type": "string" }, "description": "Design tokens consumed, by name, not raw value." },
"breakpoints": {
"type": "object",
"description": "Behavior that changes per breakpoint, keyed by breakpoint name. Every field a generator can act on is typed; free prose is confined to 'note'.",
"additionalProperties": {
"type": "object",
"properties": {
"layout": { "type": "string", "enum": ["fullWidth", "intrinsic", "fixed"] },
"hidden": { "type": "boolean", "default": false },
"tokenOverrides": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Token name to token name, e.g. {\"spacing-4\": \"spacing-2\"}. Values are token names, never raw values."
},
"minTouchTarget": { "type": "string", "description": "e.g. '44x44px'. Omit where the platform default applies." },
"note": { "type": "string", "description": "Human-only detail with no typed field yet. Generators must ignore it." }
},
"additionalProperties": false
}
},
"accessibility": {
"type": "object",
"properties": {
"role": { "type": "string" },
"requiresAccessibleName": { "type": "boolean" },
"keyboardInteraction": { "type": "array", "items": { "type": "string" } }
},
"required": ["role", "requiresAccessibleName"],
"additionalProperties": false
}
},
"required": ["id", "displayName", "variants", "props", "tokens", "breakpoints", "accessibility"],
"additionalProperties": false
}
Example: Button
{
"id": "cmp_button_01",
"displayName": "Button",
"variants": ["primary", "secondary", "destructive"],
"props": [
{ "name": "label", "type": "string", "required": true },
{ "name": "variant", "type": "enum", "enumValues": ["primary", "secondary", "destructive"], "required": false, "default": "primary" },
{ "name": "disabled", "type": "boolean", "required": false, "default": false },
{ "name": "onClick", "type": "function", "required": true }
],
"tokens": ["color-primary", "color-neutral-100", "spacing-4", "radius-2", "font-size-base"],
"breakpoints": {
"mobile": { "layout": "fullWidth", "minTouchTarget": "44x44px", "tokenOverrides": { "spacing-4": "spacing-3" } },
"desktop": { "layout": "intrinsic" }
},
"accessibility": {
"role": "button",
"requiresAccessibleName": true,
"keyboardInteraction": ["Enter: activates", "Space: activates"]
}
}
(Validated against the schema above with a JSON Schema validator before shipping.)
Key fields, why each earns its place
idversusdisplayName:idnever changes even if a designer renames the component in the design tool, so generated code and Storybook stories that referenceiddon't silently break on a rename;displayNameis purely presentational.variantsis a flat list of names, not full style definitions, because the actual visual values live behindtokens; a code generator maps each variant to the token set it uses, keeping the spec from duplicating the design system's own source of truth.propsmirrors what a component's actual API needs: name, type, whether it's required, and a default, which is exactly the information a code generator needs to scaffold a typed interface and exactly what Storybook needs to build its interactive controls panel.accessibility.requiresAccessibleNameis a boolean a generator can act on directly: if true and no accessible-name-producing prop (likelabeloraria-label) is wired up, the generated component should fail a lint check rather than ship silently unlabeled.breakpoints.tokenOverridesmaps a token name to another token name rather than to a raw value. A responsive rule written as{"padding": "8px"}would reintroduce exactly the hardcoded value thattokensexists to eliminate, and it would do it in the one place nobody thinks to audit; forcing both sides of the mapping to be token names keeps the responsive behavior inside the design system's own source of truth.
Edge cases
- A prop with
type: "enum"but noenumValuessupplied is a malformed spec the JSON Schema alone won't catch, since a plain string type is valid whether or notenumValuesaccompanies it; a code generator needs an extra validation pass beyond schema validation for cross-field rules like this. breakpointsis typed rather than free-form, and resisting the pull to leave it open is the whole test of whether this schema is actually machine-readable. The tempting move is to argue that breakpoint overrides vary too much by component to standardize and let the object hold anything. But a code generator can do precisely nothing with{"note": "Full width; minimum touch target 44x44px."}, so the one loose field becomes the one that puts a human back in the loop, which is the outcome the whole spec exists to remove. Typing the four cases that actually recur across a component library (layout mode, visibility, token substitution, touch target) covers the common path, andnotesurvives as a labelled escape hatch generators are explicitly told to ignore. When a genuinely new kind of override shows up, the correct response is to add a typed field for it and watchnoteshrink, not to widen the schema back to prose.- A component that composes other components, a Card containing a Button, isn't represented by this schema as written; extending it to reference other
ComponentSpecidentifiers under a newcomposedOffield would be the natural next step, but is out of scope for a single component's own contract.
For a date-picker component, write a TypeScript interface that documents the component contract for frontend engineers. Include props for value formats, min/max dates, disabled dates, locales, callbacks for selection and focus, validation modes, ARIA hooks, and short comments explaining expected behavior across locales.
Sample Answer
Approach
A component contract should say what a consuming engineer needs to know without reading the implementation: what values it accepts, what it guarantees back, and what it does NOT handle itself (validation display, timezone conversion). Dates are typed as plain ISO 8601 strings ("2026-09-04") rather than Date objects, since a Date carries an implicit timezone and doesn't serialize predictably across a network boundary, both real sources of bugs in date-picker components specifically.
Contract
// Component contract for a DatePicker. Values are ISO 8601 date strings
// ("2026-09-04"), so the contract has no implicit timezone and serializes
// cleanly across a network boundary.
interface DatePickerProps {
/** Currently selected date, or null when nothing is selected yet. */
value: string | null;
/** Called whenever the user picks a new date. Always receives a valid,
* in-range date; the component itself is responsible for never calling
* this with a disabled or out-of-bounds date. */
onChange: (value: string) => void;
/** Called when the input gains or loses focus, so a parent form can
* drive its own "touched" validation state. */
onFocusChange?: (focused: boolean) => void;
/** Earliest selectable date, inclusive. Omit for no lower bound. */
minDate?: string;
/** Latest selectable date, inclusive. Omit for no upper bound. */
maxDate?: string;
/** Dates the user cannot pick even inside [minDate, maxDate], e.g.
* holidays or already-booked slots. Checked per calendar cell as the
* grid renders, not against the whole dataset at once. */
disabledDates?: string[] | ((date: string) => boolean);
/** A locale tag such as "en-US", "fr-FR", or "ja-JP" (the standard
* identifier format browsers and internationalization libraries use)
* controlling month/day names, first day of week, and date formatting
* shown to the user. Defaults to the browser's locale if omitted. */
locale?: string;
/** How aggressively invalid input is reported.
* "onBlur": validate once the user leaves the field (least noisy).
* "onChange": validate as soon as a value is picked or typed.
* "off": the component shows no validation message of its own; the
* parent form owns all validation display. */
validationMode?: "onBlur" | "onChange" | "off";
/** Accessible label for the input when no visible label element points
* at it. Required if ariaLabelledBy is not provided. */
ariaLabel?: string;
/** id of an existing visible label element, preferred over ariaLabel
* when a real on-screen label exists. */
ariaLabelledBy?: string;
/** Announced to screen readers when validationMode fires and the
* current value is invalid, e.g. "Date must be after Jan 1, 2026". */
ariaErrorMessage?: string;
/** Disables the whole control, not just individual dates. */
disabled?: boolean;
}
Key points
valueandonChangeboth use plain strings, so the same contract works identically whether the calling code is a React form, a server-rendered template hydrating client state, or a test harness asserting against a value.disabledDatesaccepts either a fixed array or a predicate function, so a caller with a huge or dynamically computed disabled set (like "every day this specific resource is already booked") isn't forced to materialize every disabled date up front.validationModeis explicit about who owns showing an error, the component or the parent form, because the single most common integration bug in date pickers is two layers both trying to render a validation message and disagreeing.- The two ARIA (Accessible Rich Internet Applications) props,
ariaLabelandariaLabelledBy, are both optional individually but one of them is required in practice; a component with neither has no accessible name at all.
Edge cases
- No visible label and no
ariaLabel/ariaLabelledBysupplied: the component has no accessible name for assistive technology, so implementations should warn in development mode rather than shipping a silently unlabeled control. disabledDatesas a predicate function runs once per visible calendar cell on each render, roughly 28 to 42 calls for a month view (28 to 31 days if the grid shows only the current month, up to 42 cells if it pads to a fixed 6-row grid), not once per entry in a backing dataset, so an expensive predicate (one that does a network lookup, for instance) needs its own caching; the contract only guarantees the shape, not that the predicate is cheap.minDategreater thanmaxDate: an invalid range the component can't silently resolve; the contract should treat this as a caller error and either throw in development or render nothing selectable, rather than guessing which bound to trust.- Locale changes after a date is already selected: the underlying ISO value doesn't change, and neither does
onChange, which still emits the same"2026-09-04"string it would have under any locale. What has to re-render is every piece of user-visible text the component derives from that value: month and weekday names, the first day of the week in the grid, and the formatted date shown in the input. The one prop the caller is on the hook for isariaErrorMessage, since the component was handed a finished string and cannot translate it; a parent that swapslocalewithout also swapping that string leaves a stale-language error announced to screen readers.onFocusChangeis deliberately unaffected: it carries a boolean and no text at all, which is exactly why the contract types it that way instead of passing a formatted display value the component would then owe the caller a re-render of. Keeping all formatting outside the stored value is what makes a locale switch a pure re-render rather than a data migration.
That is every published Design Handoff and Developer Collaboration question for Full-Stack Developer so far. Browse the other topics in this category, or practice this one interactively.