Blog

Affordance in Design System Components

Let's consider a table and a chair as examples. The primary affordance of a table is its ability to support items, facilitated by its flat, horizontal surface. Similarly, the main affordance of a chair is to provide a place to sit, suggested by its features like a flat seat supported by legs and, often, a backrest to support the user's back. However, while it's not the primary affordance, a table can also be used as a seating surface. Likewise, a chair can be used to stand on when replacing a light bulb. This concept is known as "perceived affordance".

These principles are widely applied in user interface and user experience (UI/UX) design, but they can also be used to enhance the developer experience (DX). In this article, we'll describe how these concepts are crucial when developing components for design systems.

Affordance in UI/UX Design

The concept of "affordance", introduced by James J. Gibson and later adapted for UI/UX design by Don Norman, refers to the qualities or properties of an object that define its possible uses or make it clear how it can or should be used. While Gibson focused on the inherent properties of an object, Norman emphasissed the importance of "perceived affordances" in design, which are the qualities that suggest how an object might be used based on a user's interpretation and previous experiences. It reflects what users believe they can do with the object, which may not always align with its intended use.

Applying Affordance to Front-End Components

A challenge with design system components is the uncertainty around their implementation and developer expectations. Understanding what developers expect can lead to better adoption and satisfaction.

Defining Boundaries

When designing components, it's crucial to set clear boundaries around their intended uses:

  • Identify primary use cases and interactions.
  • Specify constraints and limitations.
  • Document these affordances and constraints clearly.

Developers may see additional affordances beyond the primary ones. Instead of ignoring these, consider:

  • Does this align with the design system's goals and principles?
  • Is this an edge case or a common use case?
  • Will this affect the component's integrity or usability?
  • Can this be supported without complicating the primary use case?

Answering these questions will help keep your components robust and versatile.

Implementing Boundaries

After defining boundaries, implement them in the code:

  • Offer configuration options for primary and common affordances.
  • Use clear, intuitive naming for props, methods, and events.
  • Provide validation warnings for improper usage.
  • Document proper usage with code examples and highlight anti-patterns.
  • Engage with developers to address unintended usage.

Adapt these strategies to your front-end stack to guide developers toward the best practices while allowing necessary flexibility.

Best Practices for Implementing Affordances

Keep it simple: use HTML as your example

When implementing components from a design system, consider developers' familiarity with plain HTML. By adhering to the principle of least astonishment, ensure components behave like native HTML elements, making them intuitive and easy to use.

Allow your components to accept standard HTML attributes as props. For instance, a custom button should support the attributes and events a native button should handle, utilising developers' existing HTML knowledge. A great example of this is how web components can extend existing native elements for customisation.

It's also a good practice to design components to accept children as nested elements. For example, a list component should handle child elements, like how an HTML <ul> contains <li> elements. Similarly, when building a combobox, it should accept child components like an HTML <select> contains <option> elements. This approach reflects the natural composition of HTML.

These are just some examples of how to design components. Following practices like these reduces the learning curve, making components more developer-friendly and easier to adopt.

Test with implementation early

Test your components in real-world implementations early on. This provides valuable feedback, helps validate affordances, catches edge cases, refines the API, and identifies accessibility issues.

Collaborate with a pilot team of developers, provide beta releases, set up isolated testing environments, and establish clear feedback channels. Iterating based on real-world feedback ensures components are robust, flexible, and developer-friendly.

Examples of Affordance in Front-End Components

The following examples are some use cases that we encountered during the development of component libraries for clients. These examples are built using React, but could just as well be applied in Web Components or other frameworks, like Vue or Angular.

Extending HTML elements

Let’s say we have a “Button” component. This component has three variants and can have two sizes. The code for this component now looks like this:

import { type PropsWithChildren } from 'react';

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'tertiary';
  size?: 'default' | 'large';
}

export const Button = ({
  variant = 'primary',
  size = 'default',
  children,
}: PropsWithChildren<ButtonProps>) => {
  return (
    <button className={`button--variant-${variant} button--size-${size}`}>
      {children}
    </button>
  );
};

// Implementation
export const Page = () => (
  <Button
    // this will not work
    aria-controls="[id]"
  >
    Click me!
  </Button>
);

Looks good right? Now a developer who’s implementing this component asks you how to add an aria-controls attribute to it for an accessibility issue that he has. We could just add that specific attribute to the list of allowed props, but since he could come back the next day with a new attribute that we’re missing, we should probably give him some more flexibility:

import { ComponentProps, forwardRef } from 'react';

interface ButtonProps extends ComponentProps<'button'> {
  variant?: 'primary' | 'secondary' | 'tertiary';
  size?: 'default' | 'large';
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'default', children, ...rest }, ref) => {
    return (
      <button
        ref={ref}
        className={`button--variant-${variant} button--size-${size}`}
        {...rest}
      >
        {children}
      </button>
    );
  }
);

// Implementation
export const Page = () => (
  <Button
    // this will work!
    aria-controls="[id]"
  >
    Click me!
  </Button>
);

Two things are added here:

  1. The component now accepts all attributes a button element can receive, due to extends ComponentProps<'button'>
  2. Using forwardRef, the implementor can access the underlying rendered button DOM node

Some key benefits of this approach:

  • Accessibility: Easily add aria attributes and other accessibility properties.
  • Familiarity: Mirrors the standard button API, leveraging existing HTML knowledge and enhancing component affordances.
  • Flexibility: Avoids anticipating every use case in the component's props.
  • Simplicity: Keeps the component's prop interface focused on core concerns like variant and size, while still providing extensive functionality through standard HTML attributes.

Using forwardRef allows access to the underlying button DOM node, useful for third-party integrations, programmatic focus, or measuring dimensions.

By using this pattern consistently throughout the component library, we provide a predictable development experience. This approach enhances component usability, encourages adherence to best practices, and reduces the learning curve of using the library.

Allow for experimentation

Card components are frequently found in component libraries. Here's a possible implementation:

import type { MouseEventHandler, PropsWithChildren } from 'react';

interface CardProps {
  title: string;
  body: string;
  actionTitle: string;
  onActionClick: MouseEventHandler<HTMLButtonElement>;
}

export const Card = ({
  title,
  body,
  actionTitle,
  onActionClick,
}: PropsWithChildren<CardProps>) => {
  return (
    <article className="card">
      <header className="card__header">
        <h2>{title}</h2>
      </header>
      <div className="card__body">
        <p>{body}</p>
      </div>
      <footer className="card__footer">
        <button onClick={onActionClick}>{actionTitle}</button>
      </footer>
    </article>
  );
};

// Implementation
export const Page = () => (
  <Card
    title="Axolotl"
    body="The axolotl is a paedomorphic salamander closely related to the tiger salamander."
    actionTitle="Learn more"
    onActionClick={() =>
      (window.location.href = 'https://en.wikipedia.org/wiki/Axolotl')
    }
  />
);

While this implementation is solid and functional for many cases, it can present some limitations:

  • Rigid Structure: The component strictly defines the title as an h2 and the body as a p element.
  • Limited Interactivity: The footer is limited to a single button with a predefined action.

We can refactor the Card component to accept more flexible props:

import type { PropsWithChildren, ReactNode } from 'react';

interface CardProps {
  header: ReactNode;
  body: ReactNode;
  footer: ReactNode;
}

export const Card = ({
  header,
  body,
  footer,
}: PropsWithChildren<CardProps>) => {
  return (
    <article className="card">
      <header className="card__header">{header}</header>
      <div className="card__body">{body}</div>
      <footer className="card__footer">{footer}</footer>
    </article>
  );
};

// Implementation
export const Page = () => (
  <Card
    header={<h2>Axolotl</h2>}
    body={
      <p>
        The axolotl is a paedomorphic salamander closely related to the{' '}
        <a href="https://en.wikipedia.org/wiki/Tiger_salamander">
          tiger salamander
        </a>
        .
      </p>
    }
    footer={<a href="https://en.wikipedia.org/wiki/Axolotl">Learn more</a>}
  />
);

The flexible approach offers several benefits:

  1. The customisable header allows the use of any heading level (h1, h2, etc.) or custom components, and the inclusion of links or other interactive elements.
  2. The body content can be rich and varied, as it accepts any JSX, enabling the addition of rich text, images, and other HTML elements. This setup also makes it possible to easily integrate with a CMS for dynamic content.
  3. The footer supports multiple actions by allowing buttons, links, or interactive elements, and the ability to add any event listener to them.

Using these more flexible patterns across components give developers more freedom, resulting in less friction and better adoption.

Conclusion

Implementing affordance principles in design system components is crucial for creating intuitive and flexible user interfaces. Clear boundaries, thoughtful component design, and real-world testing ensure components are versatile and maintain their primary functionality.

Encouraging experimentation and getting regular feedback helps create a better development environment and more maintainable code. By using affordance principles, design systems become easier to use and more flexible, making it simpler for developers to build web applications.

Related blog posts

← All blog posts

Need help with your design system?

See what we can do for your digital product.

Read about our service