A UX experiment for a notification bar/panel. Made with React and React-Motion.
A Pen by Guy Waldman on CodePen.
<!-- only for preview --> | |
<nav> | |
<div class="nav__notification"> | |
<span class="nav__notification__icon"></span> | |
<span class="nav__notification__num">3</span> | |
</div> | |
</nav> |
A UX experiment for a notification bar/panel. Made with React and React-Motion.
A Pen by Guy Waldman on CodePen.
// inspiration: https://dribbble.com/shots/3003823-Notification-Dropdown | |
const { | |
Component | |
} = React; | |
const { | |
Motion, | |
StaggeredMotion, | |
spring, | |
presets | |
} = ReactMotion; | |
class Media extends Component { | |
render() { | |
const cls = "nav__notifications__list__item" + (this.props.new ? " nav__notifications__list__item--new" : ""); | |
return ( | |
<li style={this.props.style} className={cls}> | |
<div className="nav__notifications__list__item__display"> | |
<Motion defaultStyle={{x:0.6}} style={{x:spring(this.props.open ? 1 : 0.6, presets.wobbly)}}> | |
{interp => <img src={this.props.imageURL} className="nav__notifications__list__item__photo" style={{transform: `scale(${interp.x})`}}/> } | |
</Motion> | |
</div> | |
<div className="nav__notifications__list__item__desc"> | |
<Motion defaultStyle={{x: 0}} style={{x: spring(this.props.open ? 0 : 1, presets.wobbly)}} > | |
{interp => <div style={{transform: `translateZ(0) translateY(${-15*interp.x}px)`, opacity: 1-interp.x}}> | |
<em>{this.props.name}</em> {this.props.action} <em> {this.props.content}</em>. | |
</div>} | |
</Motion> | |
</div> | |
</li> | |
); | |
} | |
} | |
class NotificationsBar extends Component { | |
constructor(props) { | |
super(props); | |
this.state = { | |
media: [ {imgURL: 'https://randomuser.me/api/portraits/men/74.jpg', | |
name: 'Gerlald Thompson', | |
action: 'approved your request to', | |
content: 'become friends', | |
new: true}, | |
{imgURL: 'https://randomuser.me/api/portraits/women/32.jpg', | |
name: 'Dana Newman', | |
action: 'liked', | |
content: 'your photo', | |
new: true}, | |
{imgURL: 'https://randomuser.me/api/portraits/men/93.jpg', | |
name: 'Dan Ingrid', | |
action: 'also commented on', | |
content: 'your status', | |
new: true}, | |
{imgURL: 'https://randomuser.me/api/portraits/women/16.jpg', | |
name: 'Lena Direlson', | |
action: 'checked in at', | |
content: 'Greenstreet Pub', | |
new: false}, | |
{imgURL: 'https://randomuser.me/api/portraits/men/78.jpg', | |
name: 'Dan Witherson', | |
action: 'also commented on', | |
content: 'your status', | |
new: false}] | |
}; | |
} | |
render() { | |
const {media} = this.state; | |
const motionParams = media.map(_ => Object.assign({},{h:0})); | |
return ( | |
<Motion defaultStyle={{opacity: 0}} style={{opacity: spring(this.props.open ? 1 : 0, presets.stiff)}}> | |
{interpOuter => | |
<div style={interpOuter} className="nav__notification_bar"> | |
<Motion defaultStyle={{x: 0}} style={{x: spring(this.props.open ? 0 : -5, presets.stiff)}}> | |
{interp => <h3 style={{transform: `translateY(${interp.x}px)`}}>Notifications</h3>} | |
</Motion> | |
<StaggeredMotion defaultStyles={motionParams} styles={prevInterpolatedStyles => prevInterpolatedStyles.map((_, i) => { | |
return i === 0 | |
? {h: spring(this.props.open ? 100 : 0, presets.wobbly)} | |
: {h: spring(prevInterpolatedStyles[i - 1].h)} | |
})}> | |
{interps => | |
<ul className="nav__notifications__list"> | |
{interps.map((style, i) => <Media key={i} style={{height:style.h}} imageURL={media[i].imgURL} name={media[i].name} action={media[i].action} content={media[i].content} open={this.props.open} new={media[i].new} />)} | |
</ul> | |
} | |
</StaggeredMotion> | |
</div> | |
} | |
</Motion> | |
); | |
} | |
} | |
class Notifications extends Component { | |
constructor(props) { | |
super(props); | |
this.toggleNotificationBar = this.toggleNotificationBar.bind(this); | |
this.count = 0; | |
} | |
toggleNotificationBar() { | |
this.props.toggleNotificationsBar(); | |
} | |
render() { | |
return ( | |
<div className="nav__notification"> | |
<span className="nav__notification__icon" onClick={this.toggleNotificationBar}/> | |
<Motion defaultStyle={{x: 0}} style={{x: spring(this.props.open ? 0 : 1, presets.stiff)}} > | |
{interp => <span className="nav__notification__num" style={{transform: `translateZ(0) scale(${interp.x}`, opacity: interp.x}}>3</span>} | |
</Motion> | |
<NotificationsBar open={this.props.open}/> | |
</div> | |
) | |
} | |
} | |
class NavBar extends Component { | |
constructor(props) { | |
super(props); | |
this.state = { | |
isNotificationsOpen: false | |
} | |
this.toggleNotificationsBar = this.toggleNotificationsBar.bind(this); | |
this.closeNotificationsBar = this.closeNotificationsBar.bind(this); | |
} | |
toggleNotificationsBar () { | |
this.setState({...this.state, isNotificationsOpen: !this.state.isNotificationsOpen}); | |
} | |
closeNotificationsBar() { | |
if (!this.state.isNotificationsOpen) return; | |
this.setState ( {...this.state, isNotificationsOpen: false}) | |
} | |
render() { | |
return ( | |
<nav tabIndex="0" onBlur={ this.closeNotificationsBar }> | |
<Notifications toggleNotificationsBar={this.toggleNotificationsBar} open={this.state.isNotificationsOpen} /> | |
</nav> | |
) | |
} | |
} | |
ReactDOM.render(<NavBar/>, document.body) |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.1/react.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.3.1/react-dom.min.js"></script> | |
<script src="https://unpkg.com/react-motion/build/react-motion.js"></script> |
@import 'https://fonts.googleapis.com/css?family=Roboto:300,500,700'; | |
$clr-white-1: #ecf0f1; | |
$clr-white-2: darken($clr-white-1, 3.5%); | |
$clr-t300: #7f8c8d; | |
$clr-t500: #95a5a6; | |
$clr-p300: #3498db; | |
$clr-a300: #e74c3c; | |
$trans: cubic-bezier(0.3,0,0.7,1); | |
$media-mobile: "only screen and (max-width: 720px)"; | |
body { | |
display: flex; | |
justify-content: center; | |
padding: 0; | |
margin: 0; | |
padding-top: 5rem; | |
box-sizing: border-box; | |
align-items: flex-start; | |
width: 100vw; | |
height: 100vh; | |
background: $clr-white-1; | |
font-family: 'Roboto', Arial, sans-serif; | |
font-weight: 500; | |
} | |
nav { | |
width: 70vw; | |
height: 3rem; | |
background: $clr-p300; | |
background: linear-gradient(to right, $clr-p300, lighten($clr-p300, 8%)); | |
box-shadow: inset 0 0 1px 0 rgba(black, 0.1), 0 0 5px 0 rgba(white, 0.3); | |
border-radius: 0.5em; | |
box-sizing: border-box; | |
padding: 0.5rem 3rem; | |
position: relative; | |
display: flex; | |
justify-content: flex-end; | |
align-items: center; | |
outline: none; | |
} | |
.nav__notification { | |
position: relative; | |
width: 1.5rem; | |
height: 1.5rem; | |
} | |
.nav__notification__icon { | |
cursor: default; | |
position: absolute; | |
background: $clr-white-1; | |
width: 1.5rem; | |
height: 1.5rem; | |
border-radius: 50%; | |
// will-change: transform; | |
// transition: transform $trans; | |
box-shadow: 1px 1px 3px 0 rgba(black, 0.1); | |
&:before { | |
content: ""; | |
border: 3px solid rgba($clr-white-1,.3); | |
box-sizing: border-box; | |
position: absolute; | |
border-radius: 50%; | |
background: none; | |
width: 1.5rem; | |
height: 1.5rem; | |
border-radius: 50%; | |
animation: bounceScale 3s $trans infinite 2s; | |
} | |
animation: bounce 3s $trans infinite 2s; | |
} | |
// .nav__notification__icon:hover + .nav__notification__num { | |
// transform: scale(1.1); | |
// } | |
@keyframes bounceScale { | |
0%, 20% { | |
opacity: 0; | |
border-width: 3px; | |
} | |
10% { | |
opacity: 1; | |
} | |
20%, 100% { | |
transform: scale(2); | |
border-width: 1px; | |
opacity: 0; | |
} | |
} | |
@keyframes bounce { | |
0%, 20% { | |
transform: scale(1); | |
} | |
5% { | |
transform: scale(1.15); | |
} | |
} | |
.nav__notification__num { | |
position: absolute; | |
user-select: none; | |
cursor: default; | |
font-size: 0.6rem; | |
background: $clr-a300; | |
width: 1.2rem; | |
height: 1.2rem; | |
color: $clr-white-1; | |
display: inline-flex; | |
justify-content: center; | |
align-items: center; | |
border-radius: 50%; | |
top: -0.4rem; | |
right: -0.4rem; | |
box-sizing: border-box; | |
// will-change: transform; | |
// transition: transform $trans; | |
} | |
// ------------------- nav bar ------------------- | |
.nav__notification_bar { | |
&:before { | |
// triangle | |
content: ""; | |
position: absolute; | |
top: 0; | |
right: 0; | |
width: 0; | |
height: 0; | |
transform: translate(-1rem, -100%); | |
border-left: 0.7rem solid transparent; | |
border-right: 0.7rem solid transparent; | |
border-bottom: 0.7rem solid $clr-white-1; | |
} | |
contain: layout; | |
position: absolute; | |
top: 2em; | |
right: 0; | |
width: 35vw; | |
background: $clr-white-1; | |
transform: translate(1rem, 0.5rem); | |
border-radius: 0.5rem; | |
@media #{$media-mobile} { | |
width: calc(70vw - 2em); | |
} | |
padding: 0.5rem 0.75rem; | |
box-sizing: border-box; | |
box-shadow: 0.5rem 0.5rem 2rem 0 rgba(black, 0.2); | |
h3 { | |
user-select: none; | |
cursor: default; | |
font-size: 0.7rem; | |
text-transform: uppercase; | |
color: $clr-t300; | |
letter-spacing: 0.1rem; | |
} | |
} | |
.nav__notifications__list { | |
list-style: none; | |
width: 100%; | |
box-sizing: border-box; | |
padding: 0; | |
margin: 0; | |
} | |
.nav__notifications__list__item { | |
contain: strict; | |
background: none; | |
height: 5rem; | |
width: 100%; | |
box-sizing: border-box; | |
border-radius: 0.5rem; | |
padding: 0; | |
margin: 0; | |
display: flex; | |
padding: 0 1.5rem; | |
justify-content: space-around; | |
align-items: center; | |
opacity: 0.5; | |
transition: opacity 250ms $trans; | |
&:hover { | |
opacity: 1; | |
} | |
&:not(:last-of-type) { | |
margin-bottom: 0.5rem; | |
} | |
&.nav__notifications__list__item--new { | |
background: $clr-white-2; | |
opacity: 1; | |
} | |
} | |
.nav__notifications__list__item__photo { | |
background-image: url(attr(data-url)); | |
width: 100%; | |
height: 100%; | |
border-radius: 50%; | |
transform: translateZ(0); | |
position: relative; | |
} | |
.nav__notifications__list__item__display { | |
width: 3rem; | |
height: 3rem; | |
} | |
.nav__notifications__list__item__desc { | |
height: 100%; | |
flex: 1; | |
padding: 0 1rem; | |
padding-right: 0; | |
box-sizing: border-box; | |
display: inline-flex; | |
align-items: center; | |
font-size: 0.7rem; | |
color: rgba($clr-t500,.8); | |
em { | |
text-decoration: none; | |
font-style: normal; | |
font-weight: 600; | |
color: $clr-t300; | |
} | |
} |