Pressable
A pressable is a primitive for building custom buttons.Installation
| Install | yarn add @atlaskit/primitives |
|---|---|
| Source | Bitbucket.org, (opens new window) |
| npm | @atlaskit/primitives, (opens new window) |
| Bundle | unpkg.com, (opens new window) |
Pressable is a primitive for building custom buttons with Atlassian Design System styling and
built-in event tracking. It renders a <button> element. Use pressable when existing
buttons can't be customized to fit your needs.
Default
Pressable is unstyled by default, aside from basic focus styles.
import React, { useCallback } from 'react';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
export default function Default(): React.JSX.Element {
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
return <Pressable onClick={handleClick}>Pressable</Pressable>;
}Basic styling
Pressable can be styled further using the design system styling API, cssMap.
Make sure styling indicates the interaction state using :hover and :active pseudo-classes.
Pressable does not include button motion by default. For a custom button that cannot use the Button component, add button motion tokens to the background-color transition:
const styles = cssMap({
root: {
backgroundColor: token('color.background.neutral'),
transition: token('motion.button.hovered'),
'&:hover': {
backgroundColor: token('color.background.neutral.hovered'),
},
'&:active': {
backgroundColor: token('color.background.neutral.pressed'),
transition: token('motion.button.pressed'),
},
},
});Use button motion only for Pressable elements that are semantically buttons. Keep Pressable cards, list items, navigation items, links, and form controls motion-neutral or use the motion tokens for their semantic component.
For a custom list item built with Pressable, use list-item motion tokens for the interactive state transitions:
import { cssMap } from '@atlaskit/css';
import { Pressable } from '@atlaskit/primitives/compiled';
import { token } from '@atlaskit/tokens';
const listItemStyles = cssMap({
root: {
transition: token('motion.listitem.hovered'),
'&:hover': {
transition: token('motion.listitem.hovered'),
},
'&:active': {
transition: token('motion.listitem.pressed'),
},
'&[data-selected]': {
transition: token('motion.listitem.selected'),
},
'&[data-selected]:hover': {
transition: token('motion.listitem.hovered'),
},
'&[data-selected]:active': {
transition: token('motion.listitem.pressed'),
},
'&:focus-visible': {
transition: 'none',
},
},
});
export function CustomListItem({ isSelected, children, onClick }) {
return (
<Pressable
xcss={listItemStyles.root}
data-selected={isSelected ? 'true' : undefined}
onClick={onClick}
>
{children}
</Pressable>
);
}import { type JSX, useCallback } from 'react';
import { cssMap, jsx } from '@atlaskit/css';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
import { token } from '@atlaskit/tokens';
const styles = cssMap({
pressable: {
color: token('color.text.subtle'),
fontWeight: token('font.weight.medium'),
backgroundColor: token('color.background.neutral.subtle'),
transition: token('motion.button.hovered'),
paddingBlockStart: token('space.0'),
paddingInlineEnd: token('space.0'),
paddingBlockEnd: token('space.0'),
paddingInlineStart: token('space.0'),
'&:hover': {
textDecoration: 'underline',
backgroundColor: token('color.background.neutral.subtle.hovered'),
},
'&:active': {
color: token('color.link.pressed'),
backgroundColor: token('color.background.neutral.subtle.pressed'),
transition: token('motion.button.pressed'),
},
},
});
export default function Basic(): JSX.Element {
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
return (
<Pressable onClick={handleClick} xcss={styles.pressable}>
Edit comment
</Pressable>
);
}Advanced styling
Use a combination of cssMap and other primitives for more complex designs.
import type { JSX } from 'react';
import { cssMap, jsx } from '@atlaskit/css';
import { Box } from '@atlaskit/primitives/compiled/box';
import type { TextColor } from '@atlaskit/primitives/compiled/components/types';
import { Flex } from '@atlaskit/primitives/compiled/flex';
import { Grid } from '@atlaskit/primitives/compiled/grid';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
import { Stack } from '@atlaskit/primitives/compiled/stack';
import { Text } from '@atlaskit/primitives/compiled/text';
import { token } from '@atlaskit/tokens';
const styles = cssMap({
pressable: {
paddingBlockStart: token('space.150'),
paddingInlineEnd: token('space.150'),
paddingBlockEnd: token('space.150'),
paddingInlineStart: token('space.150'),
borderRadius: token('radius.small'),
borderColor: token('color.border'),
borderWidth: token('border.width'),
borderStyle: 'solid',
color: token('color.text'),
backgroundColor: token('color.background.neutral.subtle'),
'&:hover': {
backgroundColor: token('color.background.neutral.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.neutral.subtle.pressed'),
},
},
value: {
font: token('font.heading.xlarge'),
},
grid: {
'@media (min-width: 48rem)': {
gridTemplateColumns: '1fr 1fr',
},
'@media (min-width: 64rem)': {
gridTemplateColumns: '1fr 1fr 1fr',
},
gridTemplateColumns: '1fr',
rowGap: token('space.100'),
columnGap: token('space.100'),
},
});
const ProjectStatus = ({
value,
title,
subtitle,
color,
}: {
value: number;
title: string;
subtitle: string;
color: TextColor;
}) => {
return (
<Pressable xcss={styles.pressable}>
<Flex as="span" gap="space.150" alignItems="center">
<Text color={color}>
<Box as="span" xcss={styles.value}>
{value}
</Box>
</Text>
<Stack as="span" space="space.0" alignInline="start">
<Text weight="semibold">{title}</Text>
<Text size="small" color="color.text.subtlest">
{subtitle}
</Text>
</Stack>
</Flex>
</Pressable>
);
};
export default function Styled(): JSX.Element {
return (
<Stack space="space.150">
<Text weight="bold" size="large">
You're following 5 active projects, here's the breakdown.
</Text>
<Grid xcss={styles.grid}>
<ProjectStatus
value={2}
title="On track"
subtitle="-1 from last week"
color="color.text.success"
/>
<ProjectStatus
value={1}
title="At risk"
subtitle="+1 from last week"
color="color.text.warning"
/>
<ProjectStatus value={0} title="Off track" subtitle="No change" color="color.text.danger" />
<ProjectStatus
value={2}
title="No update"
subtitle="+2 from last week"
color="color.text.discovery"
/>
<ProjectStatus value={0} title="Cancelled" subtitle="No change" color="color.text.subtle" />
<ProjectStatus
value={1}
title="Completed"
subtitle="+1 from last week"
color="color.text.information"
/>
</Grid>
</Stack>
);
}Disabled
You can disable pressable buttons with the isDisabled prop. Disabled styles should be applied and
defined conditionally using cssMap.
Disabled buttons can cause accessibility issues (disabled elements are not in the tab order) so
wherever possible, avoid using isDisabled. Instead, use validation or other techniques to show
users how to proceed.
import { type JSX, useCallback, useState } from 'react';
import { cssMap, cx, jsx } from '@atlaskit/css';
import { Inline } from '@atlaskit/primitives/compiled/inline';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
import { Stack } from '@atlaskit/primitives/compiled/stack';
import Toggle from '@atlaskit/toggle';
import { token } from '@atlaskit/tokens';
const styles = cssMap({
pressable: {
fontWeight: token('font.weight.medium'),
backgroundColor: token('color.background.neutral.subtle'),
paddingBlockStart: token('space.0'),
paddingInlineEnd: token('space.0'),
paddingBlockEnd: token('space.0'),
paddingInlineStart: token('space.0'),
},
enabled: {
color: token('color.text.subtle'),
'&:hover': {
textDecoration: 'underline',
backgroundColor: token('color.background.neutral.subtle.hovered'),
},
'&:active': {
color: token('color.link.pressed'),
backgroundColor: token('color.background.neutral.subtle.pressed'),
},
},
disabled: {
color: token('color.text.disabled'),
},
});
export default function Disabled(): JSX.Element {
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
const [isDisabled, setIsDisabled] = useState(true);
const toggleDisabled = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setIsDisabled(event.currentTarget.checked);
}, []);
return (
<Stack space="space.200" alignInline="start">
<Inline alignBlock="center" space="space.100">
<Toggle isChecked={isDisabled} id="is-disabled" onChange={toggleDisabled} />
<label htmlFor="is-disabled">Disabled</label>
</Inline>
<Pressable
isDisabled={isDisabled}
onClick={handleClick}
xcss={cx(styles.pressable, isDisabled ? styles.disabled : styles.enabled)}
>
Edit comment
</Pressable>
</Stack>
);
}For buttons without visible labels such as icon buttons, make an accessible label available using
the visually hidden component. This renders hidden text
inside the button for assistive technologies, which is preferable to an aria-label attribute
because not all screen readers translate these between languages.
Also, consider providing a tooltip to help sighted users understand the button's purpose.
import type { JSX } from 'react';
import ButtonGroup from '@atlaskit/button/button-group';
import { cssMap, jsx } from '@atlaskit/css';
import EmojiAddIcon from '@atlaskit/icon/core/emoji-add';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
import { Text } from '@atlaskit/primitives/compiled/text';
import { token } from '@atlaskit/tokens';
import Tooltip from '@atlaskit/tooltip/Tooltip';
import VisuallyHidden from '@atlaskit/visually-hidden/visually-hidden';
import { ReactionsList } from '../../utils/reactions';
const styles = cssMap({
pressable: {
backgroundColor: token('color.background.neutral.subtle'),
borderWidth: token('border.width'),
borderStyle: 'solid',
borderColor: token('color.border'),
borderRadius: token('radius.large'),
paddingInline: token('space.100'),
height: '27px',
display: 'flex',
alignItems: 'center',
'&:hover': {
backgroundColor: token('color.background.neutral.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.neutral.subtle.pressed'),
},
},
});
type ReactionButtonProps = {
emoji?: string;
name?: string;
reactions?: number;
};
const ReactionButton = ({ emoji, name, reactions }: ReactionButtonProps) => {
return (
<Tooltip
content={
name && reactions ? (
<p>
<strong>{name}</strong>
<ReactionsList reactions={reactions} />
</p>
) : (
'Add a reaction'
)
}
>
<Pressable xcss={styles.pressable}>
{emoji ? (
<Text size="small" color="color.text.subtle">
{emoji} {reactions}
</Text>
) : (
<EmojiAddIcon color={token('color.icon')} label="" />
)}
<VisuallyHidden>Add a {name && `${name} `}reaction</VisuallyHidden>
</Pressable>
</Tooltip>
);
};
export default function IconButtons(): JSX.Element {
return (
<ButtonGroup label="Reactions">
<ReactionButton emoji="👏" name="Clap" reactions={26} />
<ReactionButton emoji="❤️" name="Heart" reactions={4} />
<ReactionButton emoji="👍" name="Thumbs up" reactions={17} />
<ReactionButton />
</ButtonGroup>
);
}HTML attributes
Pressable passes all valid HTML attributes to the underlying <button> element. The type
attribute defaults to button to prevent unintentionally submitting forms.
import React from 'react';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
export default function HtmlAttributes(): React.JSX.Element {
return <Pressable type="submit">Submit form</Pressable>;
}Event tracking
Pressable has utilities to make tracking events easier. Events won't be captured unless listeners are set up to handle them.
Track events for any analytics provider
Pressable comes with built-in Atlaskit analytics support using the
Analytics next package, (opens new window), and
fires events for available listeners. Currently this is only available for onClick.
Events always fire on the atlaskit channel. To fire events on other channels as well, use the
provided analyticsEvent in onClick. To configure event data, use componentName (defaults to
'Pressable') and use analyticsContext to pass other metadata.
See the event data in the console.
import React, { useCallback } from 'react';
import AnalyticsListener from '@atlaskit/analytics-next/AnalyticsListener';
import type UIAnalyticsEvent from '@atlaskit/analytics-next/UIAnalyticsEvent';
import ButtonGroup from '@atlaskit/button/button-group';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
export default function Analytics(): React.JSX.Element {
const handleEvent = useCallback((event: UIAnalyticsEvent, channel?: string) => {
console.log(`Channel: '${channel}'`, event);
}, []);
return (
<AnalyticsListener channel="*" onEvent={handleEvent}>
<ButtonGroup label="Pressable buttons with analytics">
<Pressable>Default</Pressable>
<Pressable
onClick={(_, analyticsEvent) => {
analyticsEvent.fire('my-channel');
}}
>
Fires on "my-channel"
</Pressable>
<Pressable
componentName="MyButton"
analyticsContext={{
color: 'blue',
someId: 937458,
}}
>
Customized event data
</Pressable>
</ButtonGroup>
</AnalyticsListener>
);
}Track events for Atlassian internal services
GASv3 analytics
The Atlassian analytics bridge makes Atlaskit analytics events compatible with GASv3 (Global
Analytics Service). This can also inject an actionSubjectId to the event if required.
See the event data in the console.
import React, { useCallback } from 'react';
import AnalyticsListener from '@atlaskit/analytics-next/AnalyticsListener';
import type UIAnalyticsEvent from '@atlaskit/analytics-next/UIAnalyticsEvent';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
import {
ANALYTICS_BRIDGE_CHANNEL,
extractAWCDataFromEvent,
fireUIAnalytics,
} from '@atlassian/analytics-bridge';
export default function AnalyticsGASv3(): React.JSX.Element {
const handleEvent = useCallback((event: UIAnalyticsEvent, channel?: string) => {
console.log(`Channel: '${channel}'`, extractAWCDataFromEvent(event));
}, []);
const handleClick = useCallback(
(_: React.MouseEvent<HTMLButtonElement, MouseEvent>, analyticsEvent: UIAnalyticsEvent) => {
fireUIAnalytics(analyticsEvent, 'theActionSubjectId');
},
[],
);
return (
<AnalyticsListener channel={ANALYTICS_BRIDGE_CHANNEL} onEvent={handleEvent}>
<Pressable
onClick={handleClick}
analyticsContext={{
attributes: {
color: 'blue',
someId: 937458,
},
}}
>
Fire GASv3 compatible event
</Pressable>
</AnalyticsListener>
);
}React UFO press interactions
By default, pressable fires
React UFO (Unified Frontend Observability) press interactions, (opens new window)
for available listeners. This helps Atlassian measure performance and reliability. You can provide
more detail using the interactionName prop.
Change epic color
import { type JSX, useState } from 'react';
import { cssMap, cx, jsx } from '@atlaskit/css';
import __noop from '@atlaskit/ds-lib/noop';
import { FlagsProvider } from '@atlaskit/flag/flags-provider';
import { useFlags } from '@atlaskit/flag/use-flags';
import Heading from '@atlaskit/heading/heading';
import CheckMarkIcon from '@atlaskit/icon/core/check-mark';
import InformationIcon from '@atlaskit/icon/core/status-information';
import InteractionContext from '@atlaskit/interaction-context';
import ZoomIn from '@atlaskit/motion/zoom-in';
import { Box } from '@atlaskit/primitives/compiled/box';
import { Flex } from '@atlaskit/primitives/compiled/flex';
import { Inline } from '@atlaskit/primitives/compiled/inline';
import { Pressable } from '@atlaskit/primitives/compiled/pressable';
import { Stack } from '@atlaskit/primitives/compiled/stack';
import { token } from '@atlaskit/tokens';
import Tooltip from '@atlaskit/tooltip/Tooltip';
import VisuallyHidden from '@atlaskit/visually-hidden/visually-hidden';
const iconSpacingStyles = cssMap({
space050: {
paddingBlock: token('space.050'),
paddingInline: token('space.050'),
},
});
const styles = cssMap({
base: {
borderWidth: token('border.width'),
borderStyle: 'solid',
borderColor: token('color.border'),
borderRadius: token('radius.small'),
height: '44px',
width: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
});
const colorStyles = cssMap({
Red: {
backgroundColor: token('color.background.accent.red.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.red.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.red.subtle.pressed'),
},
},
Orange: {
backgroundColor: token('color.background.accent.orange.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.orange.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.orange.subtle.pressed'),
},
},
Yellow: {
backgroundColor: token('color.background.accent.yellow.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.yellow.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.yellow.subtle.pressed'),
},
},
Lime: {
backgroundColor: token('color.background.accent.lime.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.lime.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.lime.subtle.pressed'),
},
},
Green: {
backgroundColor: token('color.background.accent.green.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.green.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.green.subtle.pressed'),
},
},
Teal: {
backgroundColor: token('color.background.accent.teal.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.teal.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.teal.subtle.pressed'),
},
},
Blue: {
backgroundColor: token('color.background.accent.blue.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.blue.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.blue.subtle.pressed'),
},
},
Purple: {
backgroundColor: token('color.background.accent.purple.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.purple.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.purple.subtle.pressed'),
},
},
Magenta: {
backgroundColor: token('color.background.accent.magenta.subtle'),
'&:hover': {
backgroundColor: token('color.background.accent.magenta.subtle.hovered'),
},
'&:active': {
backgroundColor: token('color.background.accent.magenta.subtle.pressed'),
},
},
});
type ColorButtonProps = {
color: keyof typeof colorStyles;
isSelected?: boolean;
onClick?(): void;
};
const ColorButton = ({ color, isSelected, onClick }: ColorButtonProps) => {
return (
<Tooltip content={color}>
<Pressable
interactionName={`color-${color.toLowerCase()}`}
xcss={cx(styles.base, colorStyles[color])}
aria-pressed={isSelected}
onClick={onClick}
>
{isSelected && (
<ZoomIn>
{(props) => (
<div {...props}>
<CheckMarkIcon label="" color={token('color.icon.inverse')} />
</div>
)}
</ZoomIn>
)}
<VisuallyHidden>{color}</VisuallyHidden>
</Pressable>
</Tooltip>
);
};
const ColorPaletteButtons = () => {
const [selectedColor, setSelectedColor] = useState<keyof typeof colorStyles | null>('Red');
const { showFlag } = useFlags();
return (
<InteractionContext.Provider
value={{
hold: __noop,
tracePress: (name) => {
showFlag({
title: `Traced a press!`,
description: name,
icon: (
<Flex xcss={iconSpacingStyles.space050}>
<InformationIcon label="Info" color={token('color.icon.information')} />
</Flex>
),
isAutoDismiss: true,
});
},
}}
>
<Stack space="space.150" alignInline="start">
<Heading size="small" id="epic-heading">
Change epic color
</Heading>
<Box role="group" aria-labelledby="epic-heading">
<Inline space="space.100">
{Object.keys(colorStyles).map((color) => {
const keyColor = color as keyof typeof colorStyles;
return (
<ColorButton
key={keyColor}
color={keyColor}
isSelected={selectedColor === keyColor}
onClick={() => setSelectedColor(keyColor)}
/>
);
})}
</Inline>
</Box>
</Stack>
</InteractionContext.Provider>
);
};
export default function PressTracing(): JSX.Element {
return (
<FlagsProvider>
<ColorPaletteButtons />
</FlagsProvider>
);
}