Testimonials
A testimonial you can open and close: name and role, with the quote underneath.
This is where the testimonial goes
This is where the testimonial goes
Hover, press or tab to one to see the other 6 states.
import { Testimonials } from 'rost/components/testimonials/Testimonials'
<Testimonials name="Client 1" jobDescription="Head of product" testimonial="He found the problem before we did.">
<img src="/people/client-1.jpg" alt="" />
</Testimonials>src/components/testimonials/Testimonials.tsx
import { useId, useState, type HTMLAttributes, type ReactNode } from 'react'
import './testimonials.css'
export interface TestimonialsProps extends HTMLAttributes<HTMLDivElement> {
readonly name?: ReactNode
/** The person's role, under the name. */
readonly jobDescription?: ReactNode
/** The quote. Shown when it's open. */
readonly testimonial?: ReactNode
/** Open or closed. Leave it out and the component keeps its own state. */
readonly expanded?: boolean
/** Starts open, when it keeps its own state. */
readonly defaultExpanded?: boolean
readonly onExpandedChange?: (expanded: boolean) => void
readonly showName?: boolean
readonly showJobDescription?: boolean
/** The chevron at the end. */
readonly showAction?: boolean
/** The line underneath. */
readonly showDivider?: boolean
readonly disabled?: boolean
/** The 56px round picture: TestimonialPicture, or an image. */
readonly children?: ReactNode
/** `span` draws the header without its button, for a preview inside something that's already a link. */
readonly as?: 'button' | 'span'
}
const b = 'rost-testimonials'
/**
* A testimonial that opens to show the quote. The header is the button; the
* whole block takes its hover, press and focus. Built from
* contracts/testimonials.contract.yaml.
*/
export function Testimonials({
name = 'Name', jobDescription = 'Job description', testimonial = 'This is where the testimonial goes',
expanded, defaultExpanded = false, onExpandedChange,
showName = true, showJobDescription = true, showAction = true, showDivider = true,
disabled = false, children, className, as = 'button', ...rest
}: TestimonialsProps) {
const [own, setOwn] = useState(defaultExpanded)
const open = expanded ?? own
const quote = useId()
const toggle = () => { if (expanded === undefined) setOwn(!open); onExpandedChange?.(!open) }
const Top = as
const header = as === 'button' ? { type: 'button' as const, 'aria-expanded': open, 'aria-controls': quote, disabled, onClick: toggle } : { 'aria-disabled': disabled || undefined }
return (
<div className={[b, open && `${b}--expanded`, className].filter(Boolean).join(' ')} {...rest}>
{showDivider && <span className={`${b}__outline`} />}
<Top className={`${b}__top`} {...header}>
<span className={`${b}__left`}>
<span className={`${b}__slot`}>{children}</span>
<span className={`${b}__content`}>
{showName && <span className={`${b}__text`}>{name}</span>}
{showJobDescription && <span className={`${b}__job-description`}>{jobDescription}</span>}
</span>
</span>
{showAction && <span className={`${b}__actions`} aria-hidden="true"><span className={`${b}__icons-chevron`} /></span>}
</Top>
<div className={`${b}__bottom`} id={quote} hidden={!open}>
<p className={`${b}__testimonial`}>{testimonial}</p>
</div>
<span className={`${b}__focus-ring`} />
</div>
)
}When to use
- Used as a testimonial box or a list of quotes: name and role first, and each one opens to show the quote.
When not to use
- A quote that should always be visible. Put it on the page as text.
- More than a sentence or three. It's a quote, not an article.
Behaviour
Closed, it's a row: the picture, the name and the role, a chevron, and a line underneath. Press it and it opens: the header slides 16px right and the quote shows below in typography/sub-title. On hover and press the name goes green and the line turns green (2px on hover). Disabled fades it to opacity/gentle.
Accessibility
- Element: a
<div>holding a<button aria-expanded>(the header) and the quote, which the button controls witharia-controls. - Name: the name and the role, from the button's text.
- Keyboard: Tab to the header, Enter or Space opens and closes it.
- Focus: a 2px ring, 4px outside the whole block, while the header has
:focus-visible.
Content
The name as the person writes it. The role in a few words. The quote in their words, trimmed, never rewritten.
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| name | slot | ||
| jobDescription | slot | ||
| testimonial | slot | ||
| picture | slot | ||
| expanded | boolean | false | |
| showName | boolean | true | |
| showJobDescription | boolean | true | |
| showAction | boolean | true | |
| showDivider | boolean | true |
States: Default, Hover, Pressed, Focus, Disabled.
Where it's used
- runceanu.com
src/rost/testimonials/Testimonials.tsx.rost-testimonials
Where code differs on purpose
- runceanu.com:The open quote is `non-interactive/foreground/tertiary`, with key phrases in `non-interactive/foreground/primary`. Figma has the whole quote in `non-interactive/foreground/secondary`.I pick out what matters in each quote, the same way the footer headline does. It's an editorial choice on the site, not a change to the component.
- runceanu.com:Under 768px the name and job line wrap instead of staying on one line. The last row has no line at rest.Figma only has the desktop row, and a long job title runs off a phone screen. The list ends at the next section.
Values
Straight from the contract. Each value sits next to its token, because six tokens here all come out as #3a3a3a and the colour alone can't tell you which one is right.
Never changes
| Part | Property | Value | Token |
|---|---|---|---|
| root | display | flex | layout |
| root | align-items | center | layout |
| root | position | relative | layout |
| root | gap | 40px | spacing/gap/2xlarge |
| root | padding-top | 24px | spacing/gap/large |
| root | padding-bottom | 24px | spacing/gap/large |
| root | padding-left | 0px | spacing/gap/none |
| root | padding-right | 0px | spacing/gap/none |
| root | width | 976px | Figma frame size |
| outline | position | absolute | layout |
| outline | left | 0 | no token yet |
| outline | right | 0 | no token yet |
| outline | bottom | 0 | no token yet |
| outline | pointer-events | none | Figma frame size |
| top | display | flex | layout |
| top | align-items | center | layout |
| top | flex | 1 0 0 | layout |
| top | width | 100% | layout |
| top | gap | 24px | spacing/gap/large |
| top | padding | 0px | spacing/gap/none |
| top | background | none | Figma frame size |
| top | border | none | Figma frame size |
| top | font | inherit | Figma frame size |
| top | color | inherit | Figma frame size |
| top | text-align | left | layout |
| top | cursor | pointer | Figma frame size |
| top | outline | none | Figma frame size |
| left | display | flex | layout |
| left | align-items | center | layout |
| left | flex | 1 0 0 | layout |
| left | min-width | 1px | Figma frame size |
| left | gap | 24px | spacing/gap/large |
| slot | flex-shrink | 0 | layout |
| slot | position | relative | layout |
| slot | overflow | hidden | layout |
| slot | width | 56px | spacing/size/large |
| slot | height | 56px | spacing/size/large |
| slot | border-radius | 10000px | spacing/radius/full |
| content | display | flex | layout |
| content | flex-direction | column | layout |
| content | align-items | flex-start | layout |
| content | justify-content | center | layout |
| content | gap | 4px | spacing/gap/2xsmall |
| content | white-space | nowrap | layout |
| text | font-family | 'General Sans Variable' | font-family/base |
| text | font-weight | 500 | typography/title/font-weight/medium |
| text | line-height | 32px | typography/title/line-height |
| text | font-size | 24px | typography/title/font-size |
| text | letter-spacing | 0px | typography/title/letter-spacing |
| job-description | font-family | 'General Sans Variable' | font-family/base |
| job-description | font-weight | 400 | typography/paragraph/font-weight/regular |
| job-description | line-height | 20px | typography/paragraph/line-height |
| job-description | font-size | 14px | typography/paragraph/font-size |
| job-description | letter-spacing | 0px | typography/paragraph/letter-spacing |
| actions | display | flex | layout |
| actions | align-items | center | layout |
| actions | justify-content | center | layout |
| actions | flex-shrink | 0 | layout |
| actions | width | 32px | spacing/size/small-medium |
| actions | height | 32px | spacing/size/small-medium |
| actions | border-radius | 10000px | spacing/radius/full |
| icons-chevron | display | block | layout |
| icons-chevron | width | 16px | spacing/size/2xsmall |
| icons-chevron | height | 16px | spacing/size/2xsmall |
| icons-chevron | background | #00d669 | actions/borderless/default/icon |
| icons-chevron | mask | url(./icon-chevron-down.svg) center / contain no-repeat | on purpose: The icon file itself. |
| bottom | display | none | layout |
| bottom | width | 100% | layout |
| testimonial | flex | 1 0 0 | layout |
| testimonial | min-width | 1px | Figma frame size |
| testimonial | color | #d3d9d5 | non-interactive/foreground/secondary |
| testimonial | font-family | 'General Sans Variable' | font-family/base |
| testimonial | font-weight | 400 | typography/sub-title/font-weight/regular |
| testimonial | line-height | 28px | typography/sub-title/line-height |
| testimonial | font-size | 20px | typography/sub-title/font-size |
| testimonial | letter-spacing | 0px | typography/sub-title/letter-spacing |
| focus-ring | display | none | layout |
| focus-ring | position | absolute | layout |
| focus-ring | inset | calc(-1 * var(--spacing-gap-2xsmall)) | layout |
| focus-ring | border-style | solid | no token yet |
| focus-ring | border-width | 2px | spacing/outline/medium |
| focus-ring | border-color | #999 | project/focus/focus-ring |
| focus-ring | pointer-events | none | Figma frame size |
Changes by variant
bottom.display5
| state=Default, expanded=True | flex | layout |
| state=Hover, expanded=True | flex | layout |
| state=Pressed, expanded=True | flex | layout |
| state=Focus, expanded=True | flex | layout |
| state=Disabled, expanded=True | flex | layout |
focus-ring.display2
| state=Focus, expanded=False | block | layout |
| state=Focus, expanded=True | block | layout |
icons-chevron.mask5
| state=Default, expanded=True | url(./icon-chevron-up.svg) center / contain no-repeat | on purpose: The icon file itself. |
| state=Hover, expanded=True | url(./icon-chevron-up.svg) center / contain no-repeat | on purpose: The icon file itself. |
| state=Pressed, expanded=True | url(./icon-chevron-up.svg) center / contain no-repeat | on purpose: The icon file itself. |
| state=Focus, expanded=True | url(./icon-chevron-up.svg) center / contain no-repeat | on purpose: The icon file itself. |
| state=Disabled, expanded=True | url(./icon-chevron-up.svg) center / contain no-repeat | on purpose: The icon file itself. |
job-description.color10
| state=Default, expanded=False | #999 | project/default/sub-label |
| state=Default, expanded=True | #999 | project/default/sub-label |
| state=Hover, expanded=False | #ededed | project/hover/sub-label |
| state=Hover, expanded=True | #ededed | project/hover/sub-label |
| state=Pressed, expanded=False | #c2c7c3 | project/pressed/sub-label |
| state=Pressed, expanded=True | #c2c7c3 | project/pressed/sub-label |
| state=Focus, expanded=False | #999 | project/focus/sub-label |
| state=Focus, expanded=True | #999 | project/focus/sub-label |
| state=Disabled, expanded=False | #999 | project/disabled/sub-label |
| state=Disabled, expanded=True | #999 | project/disabled/sub-label |
outline.background10
| state=Default, expanded=False | #3a3a3a | project/default/outline |
| state=Default, expanded=True | #3a3a3a | project/default/outline |
| state=Hover, expanded=False | #00d669 | project/hover/outline |
| state=Hover, expanded=True | #00d669 | project/hover/outline |
| state=Pressed, expanded=False | #10ffa4 | project/pressed/outline |
| state=Pressed, expanded=True | #10ffa4 | project/pressed/outline |
| state=Focus, expanded=False | #3a3a3a | project/focus/outline |
| state=Focus, expanded=True | #3a3a3a | project/focus/outline |
| state=Disabled, expanded=False | #3a3a3a | project/disabled/outline |
| state=Disabled, expanded=True | #3a3a3a | project/disabled/outline |
outline.height10
| state=Default, expanded=False | 1px | spacing/outline/small |
| state=Default, expanded=True | 1px | spacing/outline/small |
| state=Hover, expanded=False | 2px | spacing/outline/medium |
| state=Hover, expanded=True | 2px | spacing/outline/medium |
| state=Pressed, expanded=False | 1px | spacing/outline/small |
| state=Pressed, expanded=True | 1px | spacing/outline/small |
| state=Focus, expanded=False | 1px | spacing/outline/small |
| state=Focus, expanded=True | 1px | spacing/outline/small |
| state=Disabled, expanded=False | 1px | spacing/outline/small |
| state=Disabled, expanded=True | 1px | spacing/outline/small |
root.align-items5
| state=Default, expanded=True | flex-start | layout |
| state=Hover, expanded=True | flex-start | layout |
| state=Pressed, expanded=True | flex-start | layout |
| state=Focus, expanded=True | flex-start | layout |
| state=Disabled, expanded=True | flex-start | layout |
root.flex-direction5
| state=Default, expanded=True | column | layout |
| state=Hover, expanded=True | column | layout |
| state=Pressed, expanded=True | column | layout |
| state=Focus, expanded=True | column | layout |
| state=Disabled, expanded=True | column | layout |
root.justify-content5
| state=Default, expanded=True | center | layout |
| state=Hover, expanded=True | center | layout |
| state=Pressed, expanded=True | center | layout |
| state=Focus, expanded=True | center | layout |
| state=Disabled, expanded=True | center | layout |
root.opacity2
| state=Disabled, expanded=False | 0.24 | opacity/gentle |
| state=Disabled, expanded=True | 0.24 | opacity/gentle |
root.padding-left7
| state=Default, expanded=True | 16px | spacing/gap/medium |
| state=Hover, expanded=False | 16px | spacing/gap/medium |
| state=Hover, expanded=True | 16px | spacing/gap/medium |
| state=Pressed, expanded=False | 16px | spacing/gap/medium |
| state=Pressed, expanded=True | 16px | spacing/gap/medium |
| state=Focus, expanded=True | 16px | spacing/gap/medium |
| state=Disabled, expanded=True | 16px | spacing/gap/medium |
text.color10
| state=Default, expanded=False | #d3d9d5 | project/default/label |
| state=Default, expanded=True | #d3d9d5 | project/default/label |
| state=Hover, expanded=False | #00d669 | project/hover/label |
| state=Hover, expanded=True | #00d669 | project/hover/label |
| state=Pressed, expanded=False | #00d669 | project/pressed/label |
| state=Pressed, expanded=True | #00d669 | project/pressed/label |
| state=Focus, expanded=False | #d3d9d5 | project/focus/label |
| state=Focus, expanded=True | #d3d9d5 | project/focus/label |
| state=Disabled, expanded=False | #d3d9d5 | project/disabled/label |
| state=Disabled, expanded=True | #d3d9d5 | project/disabled/label |
top.cursor2
| state=Disabled, expanded=False | default | Figma frame size |
| state=Disabled, expanded=True | default | Figma frame size |