Laten we een tafel en een stoel als voorbeelden nemen. De primaire affordance (bruikbaarheid) van een tafel is het ondersteunen van items, dankzij het platte, horizontale oppervlak. Op dezelfde manier is de belangrijkste affordance van een stoel het bieden van een zitplaats, gesuggereerd door kenmerken als een platte zitting ondersteund door poten en vaak een rugleuning om de rug van de gebruiker te ondersteunen. Echter, hoewel het niet de primaire affordance is, kun je een tafel ook gebruiken als zitplaats. En een stoel gebruik je ook om op te staan, bijvoorbeeld bij het vervangen van een gloeilamp. Dit concept staat bekend als "perceived affordance" (waargenomen bruikbaarheid).
Deze principes worden veel toegepast in user interface en user experience (UI/UX) design, maar je gebruikt ze ook om de developer experience (DX) te verbeteren. In dit artikel beschrijven we hoe deze concepten onmisbaar zijn bij het ontwikkelen van componenten voor design systems.
Affordance in UI/UX Design
Het concept "affordance", geïntroduceerd door James J. Gibson en later aangepast voor UI/UX-ontwerp door Don Norman, verwijst naar de kwaliteiten of eigenschappen van een object die de mogelijke toepassingen definiëren of duidelijk maken hoe het kan of moet worden gebruikt. Terwijl Gibson zich richtte op de verbonden eigenschappen van een object, benadrukte Norman het belang van "perceived affordances" in ontwerp. Dat zijn de kwaliteiten die suggereren hoe een object mogelijk gebruikt wordt op basis van de interpretatie en eerdere ervaringen van een gebruiker. Het laat zien wat gebruikers denken dat ze met het object kunnen doen, wat niet altijd overeenkomt met hoe het gebruik bedoeld is.
Affordance toepassen op Front-End Componenten
Een uitdaging bij design system componenten is de onzekerheid rond de implementatie en de verwachtingen van ontwikkelaars. Begrijpen wat ontwikkelaars verwachten, leidt tot betere acceptatie en tevredenheid.
Vaststellen van grenzen
Bij het ontwerpen van componenten is het belangrijk om duidelijke grenzen te stellen rond het bedoelde gebruik:
- Identificeer primaire use cases en interacties
- Specificeer beperkingen en limieten
- Documenteer deze affordances en beperkingen duidelijk
Developers zien misschien extra bruikbaarheid naast de primaire affordances. In plaats van dit te negeren, overweeg het volgende:
- Sluit dit aan bij de doelen en principes van het design system?
- Is dit een randgeval of een veelvoorkomende use case?
- Zal dit de integriteit of bruikbaarheid van de component beïnvloeden?
- Kan dit worden ondersteund zonder de primaire use case te compliceren?
Het beantwoorden van deze vragen helpt om je componenten robuust en veelzijdig te houden.
Grenzen toepassen
Na het vaststellen van grenzen, pas je ze toe in de code:
- Bied configuratie-opties voor primaire en veelvoorkomende affordances.
- Gebruik duidelijke, logische namen voor props, methoden en events.
- Geef validatiewaarschuwingen voor onjuist gebruik.
- Documenteer correct gebruik met codevoorbeelden en benadruk anti-patronen.
- Werk samen met ontwikkelaars om onbedoeld gebruik aan te pakken.
Pas deze strategieën toe op je front-end stack om ontwikkelaars te sturen naar de beste werkwijze, terwijl je de nodige flexibiliteit toestaat.
Best practices voor het toepassen van affordances
Houd het simpel: gebruik HTML als voorbeeld
Denk bij het toepassen van componenten uit een design system aan de bekendheid van ontwikkelaars met standaard HTML. Door te voldoen aan the principle of least astonishment, zorg je ervoor dat componenten zich gedragen als native HTML-elementen, waardoor ze intuïtief en gemakkelijk te gebruiken zijn.
Sta je componenten toe om standaard HTML-attributen als props te accepteren. Bijvoorbeeld, een aangepaste knop moet de attributen en events ondersteunen die een native knop zou afhandelen, gebruikmakend van de bestaande HTML-kennis van ontwikkelaars. Een goed voorbeeld hiervan is hoe webcomponenten bestaande native elementen kunnen uitbreiden voor aanpassing.
Het is slim om componenten te ontwerpen op een manier waarop ze onderliggende componenten als geneste elementen kunnen accepteren. Bijvoorbeeld, een lijstcomponent zou onderliggende elementen moeten kunnen verwerken, zoals een HTML <ul>
, <li>
elementen bevat. Op dezelfde manier, bij het bouwen van een keuzelijst, zou het onderliggende componenten moeten kunnen accepteren, net zoals een HTML <select>
, <option>
elementen bevat. Deze benadering weerspiegelt de natuurlijke samenstelling van HTML.
Dit zijn slechts enkele voorbeelden van hoe je componenten kunt ontwerpen. Het volgen van toepassingen zoals deze vermindert de leercurve, waardoor componenten ontwikkelaarvriendelijker en gemakkelijker toe te passen zijn.
Test vroeg met implementatie
Test je componenten vroeg in real-world implementaties. Dit geeft waardevolle feedback, helpt affordances te valideren, vangt randgevallen af, verfijnt de API en identificeert toegankelijkheidsproblemen.
Werk samen met een pilot team van ontwikkelaars, bied bètareleases aan, stel geïsoleerde testomgevingen op en stel duidelijke feedbackkanalen in. Itereren op basis van real-world feedback zorgt ervoor dat componenten robuust, flexibel en ontwikkelaarvriendelijk zijn.
Voorbeelden van Affordance in Front-End Componenten
De volgende voorbeelden zijn enkele use cases die we tegenkwamen tijdens de ontwikkeling van componenten libraries voor klanten. Deze voorbeelden zijn gebouwd met React, maar kunnen net zo goed worden toegepast in webcomponenten of andere frameworks, zoals Vue of Angular.
HTML-elementen uitbreiden
Stel dat we een “Button” component hebben. Dit component heeft drie varianten en kan twee maten hebben. De code voor deze component ziet er nu zo uit:
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>
);
Ziet er goed uit, toch? Nu vraagt een ontwikkelaar die dit component implementeert hoe hij een aria-controls
attribuut eraan toevoegt voor een toegankelijkheidsprobleem dat hij heeft. We kunnen dat specifieke attribuut toevoegen aan de lijst van toegestane props, maar aangezien hij de volgende dag terug kan komen met een nieuw attribuut dat we missen, moeten we hem waarschijnlijk wat meer flexibiliteit geven:
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>
);
Twee dingen zijn hier toegevoegd:
- Het component accepteert nu alle attributen die een button element kan ontvangen, doordat we
extends ComponentProps<'button'>
gebruiken - Met
forwardRef
krijgt degene die het implementeert toegang tot de onderliggende gerenderde button DOM node
Een aantal belangrijke voordelen hiervan:
- Toegankelijkheid: Gemakkelijk aria-attributen en andere toegankelijkheidseigenschappen toevoegen.
- Bekendheid: Spiegelt de standaard button API, gebruikmakend van bestaande HTML-kennis en verbeterde component affordances.
- Flexibiliteit: Vermijd het anticiperen op elke use case in de props van de component.
- Eenvoud: Houd de props-interface van de component gefocust op kernzaken zoals variant en grootte, terwijl nog steeds uitgebreide functionaliteit wordt geboden via standaard HTML-attributen.
Het gebruik van forwardRef
biedt toegang tot de onderliggende button DOM node, nuttig voor integraties van derden, programmatische focus of het meten van afmetingen.
Door dit patroon consistent door de componenten library te gebruiken, bieden we een voorspelbare developer expierience. Deze benadering verbetert de bruikbaarheid van componenten, moedigt naleving van best practices aan en vermindert de leercurve van het gebruik van de library.
Ruimte voor experimentatie
Card componenten zie je vaak terug in componenten libraries. Dit is een mogelijke toepassing:
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')
}
/>
);
Hoewel deze implementatie stabiel en functioneel is voor veel gevallen, kan het enkele beperkingen hebben:
- Onveranderlijke structuur: Het component definieert de titel enkel als een h2 en het lichaam als een p element.
- Beperkte interactiviteit: De footer is beperkt tot een enkele knop met een vooraf gedefinieerde actie.
We kunnen het Card-component herstructureren om flexibelere eigenschappen te accepteren:
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>}
/>
);
De flexibele benadering biedt verschillende voordelen:
- De aanpasbare header maakt het gebruik van elk kopniveau (h1, h2, enz.) of aangepaste componenten mogelijk, evenals het opnemen van links of andere interactieve elementen.
- De body-inhoud kan uitgebreid en gevarieerd zijn, omdat het elke JSX accepteert, waardoor het toevoegen van uitgebreide tekst, afbeeldingen en andere HTML-elementen mogelijk is. Deze setup maakt het ook mogelijk om gemakkelijk te integreren met een CMS voor dynamische inhoud.
- De footer ondersteunt meerdere acties door knoppen, links of interactieve elementen toe te staan en de mogelijkheid om elke eventlistener eraan toe te voegen.
Door deze meer flexibele patronen consequent toe te passen in componenten, krijgen ontwikkelaars meer vrijheid, wat resulteert in minder weerstand en betere acceptatie.
Conclusie
Het implementeren van affordance principes in design system componenten is cruciaal voor het creëren van intuïtieve en flexibele gebruikersinterfaces. Duidelijke grenzen, doordacht component ontwerp en testen in de echte wereld zorgen ervoor dat componenten veelzijdig zijn en hun primaire functionaliteit behouden.
Het aanmoedigen van experimenten en het regelmatig verkrijgen van feedback helpt bij het creëren van een betere ontwikkelomgeving en onderhoudbare code. Door gebruik te maken van affordance principes, worden design systems gemakkelijker te gebruiken en flexibeler, waardoor het eenvoudiger wordt voor ontwikkelaars om webapplicaties te bouwen.